diff --git a/.github/workflows/benchmark-pr.yml b/.github/workflows/benchmark-pr.yml deleted file mode 100644 index e8ab8ad..0000000 --- a/.github/workflows/benchmark-pr.yml +++ /dev/null @@ -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.'); - } \ No newline at end of file diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index 628a1f4..0000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -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 'n8n-mcp Benchmarks

n8n-mcp Benchmarks

Benchmark data will appear here after the first run.

' > 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 \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index daae80b..d5a6e9c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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' - - - - n8n-mcp Test Results - - - -

n8n-mcp Test Results

-
-

Test Reports

- -
-
-

Coverage Reports

- -
-
-

Benchmark Results

- -
-
-

Metadata

- -
-
-

Generated at $(date -u +%Y-%m-%dT%H:%M:%SZ)

-

Run: #${{ github.run_number }} | SHA: ${{ github.sha }}

-
- - - 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 \ No newline at end of file + fail-on-empty: false \ No newline at end of file diff --git a/data/nodes.db b/data/nodes.db index 31da762..37df643 100644 Binary files a/data/nodes.db and b/data/nodes.db differ diff --git a/dist/database/node-repository.d.ts.map b/dist/database/node-repository.d.ts.map index aba0a8a..b0e777a 100644 --- a/dist/database/node-repository.d.ts.map +++ b/dist/database/node-repository.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"node-repository.d.ts","sourceRoot":"","sources":["../../src/database/node-repository.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"node-repository.d.ts","sourceRoot":"","sources":["../../src/database/node-repository.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/database/node-repository.js b/dist/database/node-repository.js index 46bc06d..67736a8 100644 --- a/dist/database/node-repository.js +++ b/dist/database/node-repository.js @@ -12,8 +12,9 @@ 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 INTO nodes ( + 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, @@ -21,39 +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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(node_type) DO UPDATE SET - package_name = excluded.package_name, - display_name = excluded.display_name, - description = excluded.description, - category = excluded.category, - development_style = excluded.development_style, - is_ai_tool = excluded.is_ai_tool, - is_trigger = excluded.is_trigger, - is_webhook = excluded.is_webhook, - is_versioned = excluded.is_versioned, - is_tool_variant = excluded.is_tool_variant, - tool_variant_of = excluded.tool_variant_of, - has_tool_variant = excluded.has_tool_variant, - version = excluded.version, - documentation = excluded.documentation, - properties_schema = excluded.properties_schema, - operations = excluded.operations, - credentials_required = excluded.credentials_required, - outputs = excluded.outputs, - output_names = excluded.output_names, - is_community = excluded.is_community, - is_verified = excluded.is_verified, - author_name = excluded.author_name, - author_github_url = excluded.author_github_url, - npm_package_name = excluded.npm_package_name, - npm_version = excluded.npm_version, - npm_downloads = excluded.npm_downloads, - community_fetched_at = excluded.community_fetched_at, - updated_at = CURRENT_TIMESTAMP + 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); diff --git a/dist/database/node-repository.js.map b/dist/database/node-repository.js.map index 213361a..777fd27 100644 --- a/dist/database/node-repository.js.map +++ b/dist/database/node-repository.js.map @@ -1 +1 @@ -{"version":3,"file":"node-repository.js","sourceRoot":"","sources":["../../src/database/node-repository.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"node-repository.js","sourceRoot":"","sources":["../../src/database/node-repository.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/scripts/rebuild.js b/dist/scripts/rebuild.js index 15ba3e2..4d691c5 100644 --- a/dist/scripts/rebuild.js +++ b/dist/scripts/rebuild.js @@ -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); diff --git a/dist/scripts/rebuild.js.map b/dist/scripts/rebuild.js.map index 6a560e1..277881b 100644 --- a/dist/scripts/rebuild.js.map +++ b/dist/scripts/rebuild.js.map @@ -1 +1 @@ -{"version":3,"file":"rebuild.js","sourceRoot":"","sources":["../../src/scripts/rebuild.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"rebuild.js","sourceRoot":"","sources":["../../src/scripts/rebuild.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/package.json b/package.json index d2be8d0..85fe917 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/compare-benchmarks.js b/scripts/compare-benchmarks.js deleted file mode 100644 index 7486fea..0000000 --- a/scripts/compare-benchmarks.js +++ /dev/null @@ -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 [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); \ No newline at end of file diff --git a/scripts/format-benchmark-results.js b/scripts/format-benchmark-results.js deleted file mode 100755 index 516f048..0000000 --- a/scripts/format-benchmark-results.js +++ /dev/null @@ -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(); -} \ No newline at end of file diff --git a/scripts/generate-benchmark-stub.js b/scripts/generate-benchmark-stub.js deleted file mode 100644 index c2bfbc4..0000000 --- a/scripts/generate-benchmark-stub.js +++ /dev/null @@ -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}`); \ No newline at end of file diff --git a/scripts/run-benchmarks-ci.js b/scripts/run-benchmarks-ci.js deleted file mode 100755 index 9fb6404..0000000 --- a/scripts/run-benchmarks-ci.js +++ /dev/null @@ -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}`); -}); \ No newline at end of file diff --git a/scripts/vitest-benchmark-json-reporter.js b/scripts/vitest-benchmark-json-reporter.js deleted file mode 100644 index a8fe936..0000000 --- a/scripts/vitest-benchmark-json-reporter.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/scripts/vitest-benchmark-reporter.ts b/scripts/vitest-benchmark-reporter.ts deleted file mode 100644 index e26b5e3..0000000 --- a/scripts/vitest-benchmark-reporter.ts +++ /dev/null @@ -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); - } - } - } -} \ No newline at end of file diff --git a/src/scripts/rebuild.ts b/src/scripts/rebuild.ts index f7eed5c..ab47fac 100644 --- a/src/scripts/rebuild.ts +++ b/src/scripts/rebuild.ts @@ -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 { diff --git a/tests/benchmarks/README.md b/tests/benchmarks/README.md deleted file mode 100644 index 6f3f623..0000000 --- a/tests/benchmarks/README.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/benchmarks/database-queries.bench.ts b/tests/benchmarks/database-queries.bench.ts deleted file mode 100644 index 481c403..0000000 --- a/tests/benchmarks/database-queries.bench.ts +++ /dev/null @@ -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 - }); -}); \ No newline at end of file diff --git a/tests/benchmarks/index.ts b/tests/benchmarks/index.ts deleted file mode 100644 index 8adb81c..0000000 --- a/tests/benchmarks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Export all benchmark suites -export * from './database-queries.bench'; -export * from './mcp-tools.bench'; \ No newline at end of file diff --git a/tests/benchmarks/mcp-tools.bench.ts b/tests/benchmarks/mcp-tools.bench.ts deleted file mode 100644 index c4a527a..0000000 --- a/tests/benchmarks/mcp-tools.bench.ts +++ /dev/null @@ -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 - }); -}); diff --git a/tests/benchmarks/mcp-tools.bench.ts.disabled b/tests/benchmarks/mcp-tools.bench.ts.disabled deleted file mode 100644 index 02ff54b..0000000 --- a/tests/benchmarks/mcp-tools.bench.ts.disabled +++ /dev/null @@ -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 - }); -}); \ No newline at end of file diff --git a/tests/benchmarks/mcp-tools.bench.ts.skip b/tests/benchmarks/mcp-tools.bench.ts.skip deleted file mode 100644 index cf57e20..0000000 --- a/tests/benchmarks/mcp-tools.bench.ts.skip +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/benchmarks/node-loading.bench.ts.disabled b/tests/benchmarks/node-loading.bench.ts.disabled deleted file mode 100644 index b21a4c2..0000000 --- a/tests/benchmarks/node-loading.bench.ts.disabled +++ /dev/null @@ -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 - }); -}); \ No newline at end of file diff --git a/tests/benchmarks/search-operations.bench.ts.disabled b/tests/benchmarks/search-operations.bench.ts.disabled deleted file mode 100644 index f4d85af..0000000 --- a/tests/benchmarks/search-operations.bench.ts.disabled +++ /dev/null @@ -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 - }); -}); \ No newline at end of file diff --git a/tests/benchmarks/validation-performance.bench.ts.disabled b/tests/benchmarks/validation-performance.bench.ts.disabled deleted file mode 100644 index 923fb49..0000000 --- a/tests/benchmarks/validation-performance.bench.ts.disabled +++ /dev/null @@ -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 - }); -}); \ No newline at end of file diff --git a/tests/examples/using-database-utils.test.ts b/tests/examples/using-database-utils.test.ts deleted file mode 100644 index 6f50716..0000000 --- a/tests/examples/using-database-utils.test.ts +++ /dev/null @@ -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 -): Promise { - const start = performance.now(); - await operation(); - const duration = performance.now() - start; - console.log(`[Performance] ${name}: ${duration.toFixed(2)}ms`); - return duration; -} \ No newline at end of file diff --git a/tests/integration/ci/database-population.test.ts b/tests/integration/ci/database-population.test.ts index 1c33779..2236ca0 100644 --- a/tests/integration/ci/database-population.test.ts +++ b/tests/integration/ci/database-population.test.ts @@ -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', () => { diff --git a/tests/integration/database/node-fts5-search.test.ts b/tests/integration/database/node-fts5-search.test.ts index 1681d13..7fa04a6 100644 --- a/tests/integration/database/node-fts5-search.test.ts +++ b/tests/integration/database/node-fts5-search.test.ts @@ -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(() => { diff --git a/tests/integration/security/rate-limiting.test.ts b/tests/integration/security/rate-limiting.test.ts deleted file mode 100644 index 24f35f5..0000000 --- a/tests/integration/security/rate-limiting.test.ts +++ /dev/null @@ -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); -}); diff --git a/tests/integration/telemetry/mcp-telemetry.test.ts b/tests/integration/telemetry/mcp-telemetry.test.ts deleted file mode 100644 index 8fd25c5..0000000 --- a/tests/integration/telemetry/mcp-telemetry.test.ts +++ /dev/null @@ -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); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/database/database-adapter-unit.test.ts b/tests/unit/database/database-adapter-unit.test.ts deleted file mode 100644 index 2f223fc..0000000 --- a/tests/unit/database/database-adapter-unit.test.ts +++ /dev/null @@ -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: (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; - 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 = (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); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/examples/using-n8n-nodes-base-mock.test.ts b/tests/unit/examples/using-n8n-nodes-base-mock.test.ts deleted file mode 100644 index 448d2d4..0000000 --- a/tests/unit/examples/using-n8n-nodes-base-mock.test.ts +++ /dev/null @@ -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 = { - 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); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/http-server-n8n-reinit.test.ts b/tests/unit/http-server-n8n-reinit.test.ts deleted file mode 100644 index c2a280c..0000000 --- a/tests/unit/http-server-n8n-reinit.test.ts +++ /dev/null @@ -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); - }); -}); \ No newline at end of file diff --git a/tests/unit/http-server/session-persistence.test.ts b/tests/unit/http-server/session-persistence.test.ts index 143f8f4..afc9fc6 100644 --- a/tests/unit/http-server/session-persistence.test.ts +++ b/tests/unit/http-server/session-persistence.test.ts @@ -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, diff --git a/tests/unit/mcp-engine/session-persistence.test.ts b/tests/unit/mcp-engine/session-persistence.test.ts index cf1f76b..5ccac6d 100644 --- a/tests/unit/mcp-engine/session-persistence.test.ts +++ b/tests/unit/mcp-engine/session-persistence.test.ts @@ -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, diff --git a/tests/unit/mcp/handlers-n8n-manager-simple.test.ts b/tests/unit/mcp/handlers-n8n-manager-simple.test.ts deleted file mode 100644 index 4045a02..0000000 --- a/tests/unit/mcp/handlers-n8n-manager-simple.test.ts +++ /dev/null @@ -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(''); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/ai-node-validator.test.ts b/tests/unit/services/ai-node-validator.test.ts deleted file mode 100644 index cbd9442..0000000 --- a/tests/unit/services/ai-node-validator.test.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/tests/unit/services/ai-tool-validators.test.ts b/tests/unit/services/ai-validators.test.ts similarity index 52% rename from tests/unit/services/ai-tool-validators.test.ts rename to tests/unit/services/ai-validators.test.ts index deaadb3..78ea593 100644 --- a/tests/unit/services/ai-tool-validators.test.ts +++ b/tests/unit/services/ai-validators.test.ts @@ -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); }); diff --git a/tests/unit/services/config-validator-basic.test.ts b/tests/unit/services/config-validator-basic.test.ts deleted file mode 100644 index 9dcf4d7..0000000 --- a/tests/unit/services/config-validator-basic.test.ts +++ /dev/null @@ -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); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/config-validator-cnd.test.ts b/tests/unit/services/config-validator-cnd.test.ts deleted file mode 100644 index 267212c..0000000 --- a/tests/unit/services/config-validator-cnd.test.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/tests/unit/services/config-validator-edge-cases.test.ts b/tests/unit/services/config-validator-edge-cases.test.ts deleted file mode 100644 index 1d33145..0000000 --- a/tests/unit/services/config-validator-edge-cases.test.ts +++ /dev/null @@ -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 = {}; - 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); - } - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/config-validator-node-specific.test.ts b/tests/unit/services/config-validator-node-specific.test.ts deleted file mode 100644 index 36f446f..0000000 --- a/tests/unit/services/config-validator-node-specific.test.ts +++ /dev/null @@ -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); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/config-validator.test.ts b/tests/unit/services/config-validator.test.ts new file mode 100644 index 0000000..ace2290 --- /dev/null +++ b/tests/unit/services/config-validator.test.ts @@ -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 = {}; + 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); + }); + }); +}); diff --git a/tests/unit/services/enhanced-config-validator-integration.test.ts b/tests/unit/services/enhanced-config-validator-integration.test.ts deleted file mode 100644 index 2ed8f71..0000000 --- a/tests/unit/services/enhanced-config-validator-integration.test.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; -import { ResourceSimilarityService } from '@/services/resource-similarity-service'; -import { OperationSimilarityService } from '@/services/operation-similarity-service'; -import { NodeRepository } from '@/database/node-repository'; - -// Mock similarity services -vi.mock('@/services/resource-similarity-service'); -vi.mock('@/services/operation-similarity-service'); - -describe('EnhancedConfigValidator - Integration Tests', () => { - let mockResourceService: any; - let mockOperationService: any; - let mockRepository: any; - - beforeEach(() => { - mockRepository = { - getNode: vi.fn(), - getNodeOperations: vi.fn().mockReturnValue([]), - getNodeResources: vi.fn().mockReturnValue([]), - getOperationsForResource: vi.fn().mockReturnValue([]), - getDefaultOperationForResource: vi.fn().mockReturnValue(undefined), - getNodePropertyDefaults: vi.fn().mockReturnValue({}) - }; - - mockResourceService = { - findSimilarResources: vi.fn().mockReturnValue([]) - }; - - mockOperationService = { - findSimilarOperations: vi.fn().mockReturnValue([]) - }; - - // Mock the constructors to return our mock services - vi.mocked(ResourceSimilarityService).mockImplementation(() => mockResourceService); - vi.mocked(OperationSimilarityService).mockImplementation(() => mockOperationService); - - // Initialize the similarity services (this will create the service instances) - EnhancedConfigValidator.initializeSimilarityServices(mockRepository); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('similarity service integration', () => { - it('should initialize similarity services when initializeSimilarityServices is called', () => { - // Services should be created when initializeSimilarityServices was called in beforeEach - expect(ResourceSimilarityService).toHaveBeenCalled(); - expect(OperationSimilarityService).toHaveBeenCalled(); - }); - - it('should use resource similarity service for invalid resource errors', () => { - const config = { - resource: 'invalidResource', - operation: 'send' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' }, - { value: 'channel', name: 'Channel' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'] - } - }, - options: [ - { value: 'send', name: 'Send Message' } - ] - } - ]; - - // Mock resource similarity suggestions - mockResourceService.findSimilarResources.mockReturnValue([ - { - value: 'message', - confidence: 0.8, - reason: 'Similar resource name', - availableOperations: ['send', 'update'] - } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith( - 'nodes-base.slack', - 'invalidResource', - expect.any(Number) - ); - - // Should have suggestions in the result - expect(result.suggestions).toBeDefined(); - expect(result.suggestions.length).toBeGreaterThan(0); - }); - - it('should use operation similarity service for invalid operation errors', () => { - const config = { - resource: 'message', - operation: 'invalidOperation' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'] - } - }, - options: [ - { value: 'send', name: 'Send Message' }, - { value: 'update', name: 'Update Message' } - ] - } - ]; - - // Mock operation similarity suggestions - mockOperationService.findSimilarOperations.mockReturnValue([ - { - value: 'send', - confidence: 0.9, - reason: 'Very similar - likely a typo', - resource: 'message' - } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(mockOperationService.findSimilarOperations).toHaveBeenCalledWith( - 'nodes-base.slack', - 'invalidOperation', - 'message', - expect.any(Number) - ); - - // Should have suggestions in the result - expect(result.suggestions).toBeDefined(); - expect(result.suggestions.length).toBeGreaterThan(0); - }); - - it('should handle similarity service errors gracefully', () => { - const config = { - resource: 'invalidResource', - operation: 'send' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - } - ]; - - // Mock service to throw error - mockResourceService.findSimilarResources.mockImplementation(() => { - throw new Error('Service error'); - }); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Should not crash and still provide basic validation - expect(result).toBeDefined(); - expect(result.valid).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should not call similarity services for valid configurations', () => { - // Mock repository to return valid resources for this test - mockRepository.getNodeResources.mockReturnValue([ - { value: 'message', name: 'Message' }, - { value: 'channel', name: 'Channel' } - ]); - // Mock getNodeOperations to return valid operations - mockRepository.getNodeOperations.mockReturnValue([ - { value: 'send', name: 'Send Message' } - ]); - - const config = { - resource: 'message', - operation: 'send', - channel: '#general', // Add required field for Slack send - text: 'Test message' // Add required field for Slack send - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'] - } - }, - options: [ - { value: 'send', name: 'Send Message' } - ] - } - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Should not call similarity services for valid config - expect(mockResourceService.findSimilarResources).not.toHaveBeenCalled(); - expect(mockOperationService.findSimilarOperations).not.toHaveBeenCalled(); - expect(result.valid).toBe(true); - }); - - it('should limit suggestion count when calling similarity services', () => { - const config = { - resource: 'invalidResource' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - } - ]; - - EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith( - 'nodes-base.slack', - 'invalidResource', - 3 // Should limit to 3 suggestions - ); - }); - }); - - describe('error enhancement with suggestions', () => { - it('should enhance resource validation errors with suggestions', () => { - const config = { - resource: 'msgs' // Typo for 'message' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' }, - { value: 'channel', name: 'Channel' } - ] - } - ]; - - // Mock high-confidence suggestion - mockResourceService.findSimilarResources.mockReturnValue([ - { - value: 'message', - confidence: 0.85, - reason: 'Very similar - likely a typo', - availableOperations: ['send', 'update', 'delete'] - } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Should have enhanced error with suggestion - const resourceError = result.errors.find(e => e.property === 'resource'); - expect(resourceError).toBeDefined(); - expect(resourceError!.suggestion).toBeDefined(); - expect(resourceError!.suggestion).toContain('message'); - }); - - it('should enhance operation validation errors with suggestions', () => { - const config = { - resource: 'message', - operation: 'sned' // Typo for 'send' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'] - } - }, - options: [ - { value: 'send', name: 'Send Message' }, - { value: 'update', name: 'Update Message' } - ] - } - ]; - - // Mock high-confidence suggestion - mockOperationService.findSimilarOperations.mockReturnValue([ - { - value: 'send', - confidence: 0.9, - reason: 'Almost exact match - likely a typo', - resource: 'message', - description: 'Send Message' - } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Should have enhanced error with suggestion - const operationError = result.errors.find(e => e.property === 'operation'); - expect(operationError).toBeDefined(); - expect(operationError!.suggestion).toBeDefined(); - expect(operationError!.suggestion).toContain('send'); - }); - - it('should not enhance errors when no good suggestions are available', () => { - const config = { - resource: 'completelyWrongValue' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - } - ]; - - // Mock low-confidence suggestions - mockResourceService.findSimilarResources.mockReturnValue([ - { - value: 'message', - confidence: 0.2, // Too low confidence - reason: 'Possibly related resource' - } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Should not enhance error due to low confidence - const resourceError = result.errors.find(e => e.property === 'resource'); - expect(resourceError).toBeDefined(); - expect(resourceError!.suggestion).toBeUndefined(); - }); - - it('should provide multiple operation suggestions when resource is known', () => { - const config = { - resource: 'message', - operation: 'invalidOp' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'] - } - }, - options: [ - { value: 'send', name: 'Send Message' }, - { value: 'update', name: 'Update Message' }, - { value: 'delete', name: 'Delete Message' } - ] - } - ]; - - // Mock multiple suggestions - mockOperationService.findSimilarOperations.mockReturnValue([ - { value: 'send', confidence: 0.7, reason: 'Similar operation' }, - { value: 'update', confidence: 0.6, reason: 'Similar operation' }, - { value: 'delete', confidence: 0.5, reason: 'Similar operation' } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Should include multiple suggestions in the result - expect(result.suggestions.length).toBeGreaterThan(2); - const operationSuggestions = result.suggestions.filter(s => - s.includes('send') || s.includes('update') || s.includes('delete') - ); - expect(operationSuggestions.length).toBeGreaterThan(0); - }); - }); - - describe('confidence thresholds and filtering', () => { - it('should only use high confidence resource suggestions', () => { - const config = { - resource: 'invalidResource' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - } - ]; - - // Mock mixed confidence suggestions - mockResourceService.findSimilarResources.mockReturnValue([ - { value: 'message1', confidence: 0.9, reason: 'High confidence' }, - { value: 'message2', confidence: 0.4, reason: 'Low confidence' }, - { value: 'message3', confidence: 0.7, reason: 'Medium confidence' } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Should only use suggestions above threshold - const resourceError = result.errors.find(e => e.property === 'resource'); - expect(resourceError?.suggestion).toBeDefined(); - // Should prefer high confidence suggestion - expect(resourceError!.suggestion).toContain('message1'); - }); - - it('should only use high confidence operation suggestions', () => { - const config = { - resource: 'message', - operation: 'invalidOperation' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'] - } - }, - options: [ - { value: 'send', name: 'Send Message' } - ] - } - ]; - - // Mock mixed confidence suggestions - mockOperationService.findSimilarOperations.mockReturnValue([ - { value: 'send', confidence: 0.95, reason: 'Very high confidence' }, - { value: 'post', confidence: 0.3, reason: 'Low confidence' } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Should only use high confidence suggestion - const operationError = result.errors.find(e => e.property === 'operation'); - expect(operationError?.suggestion).toBeDefined(); - expect(operationError!.suggestion).toContain('send'); - expect(operationError!.suggestion).not.toContain('post'); - }); - }); - - describe('integration with existing validation logic', () => { - it('should work with minimal validation mode', () => { - // Mock repository to return empty resources - mockRepository.getNodeResources.mockReturnValue([]); - - const config = { - resource: 'invalidResource' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - } - ]; - - mockResourceService.findSimilarResources.mockReturnValue([ - { value: 'message', confidence: 0.8, reason: 'Similar' } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'minimal', - 'ai-friendly' - ); - - // Should still enhance errors in minimal mode - expect(mockResourceService.findSimilarResources).toHaveBeenCalled(); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should work with strict validation profile', () => { - // Mock repository to return valid resource but no operations - mockRepository.getNodeResources.mockReturnValue([ - { value: 'message', name: 'Message' } - ]); - mockRepository.getOperationsForResource.mockReturnValue([]); - - const config = { - resource: 'message', - operation: 'invalidOp' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'] - } - }, - options: [ - { value: 'send', name: 'Send Message' } - ] - } - ]; - - mockOperationService.findSimilarOperations.mockReturnValue([ - { value: 'send', confidence: 0.8, reason: 'Similar' } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'strict' - ); - - // Should enhance errors regardless of profile - expect(mockOperationService.findSimilarOperations).toHaveBeenCalled(); - const operationError = result.errors.find(e => e.property === 'operation'); - expect(operationError?.suggestion).toBeDefined(); - }); - - it('should preserve original error properties when enhancing', () => { - const config = { - resource: 'invalidResource' - }; - - const properties = [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'message', name: 'Message' } - ] - } - ]; - - mockResourceService.findSimilarResources.mockReturnValue([ - { value: 'message', confidence: 0.8, reason: 'Similar' } - ]); - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - properties, - 'operation', - 'ai-friendly' - ); - - const resourceError = result.errors.find(e => e.property === 'resource'); - - // Should preserve original error properties - expect(resourceError?.type).toBeDefined(); - expect(resourceError?.property).toBe('resource'); - expect(resourceError?.message).toBeDefined(); - - // Should add suggestion without overriding other properties - expect(resourceError?.suggestion).toBeDefined(); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/enhanced-config-validator-operations.test.ts b/tests/unit/services/enhanced-config-validator-operations.test.ts deleted file mode 100644 index 54a2dc4..0000000 --- a/tests/unit/services/enhanced-config-validator-operations.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -/** - * Tests for EnhancedConfigValidator operation and resource validation - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; -import { NodeRepository } from '../../../src/database/node-repository'; -import { createTestDatabase } from '../../utils/database-utils'; - -describe('EnhancedConfigValidator - Operation and Resource Validation', () => { - let repository: NodeRepository; - let testDb: any; - - beforeEach(async () => { - testDb = await createTestDatabase(); - repository = testDb.nodeRepository; - - // Initialize similarity services - EnhancedConfigValidator.initializeSimilarityServices(repository); - - // Add Google Drive test node - const googleDriveNode = { - nodeType: 'nodes-base.googleDrive', - packageName: 'n8n-nodes-base', - displayName: 'Google Drive', - description: 'Access Google Drive', - category: 'transform', - style: 'declarative' as const, - isAITool: false, - isTrigger: false, - isWebhook: false, - isVersioned: true, - version: '1', - properties: [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'file', name: 'File' }, - { value: 'folder', name: 'Folder' }, - { value: 'fileFolder', name: 'File & Folder' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['file'] - } - }, - options: [ - { value: 'copy', name: 'Copy' }, - { value: 'delete', name: 'Delete' }, - { value: 'download', name: 'Download' }, - { value: 'list', name: 'List' }, - { value: 'share', name: 'Share' }, - { value: 'update', name: 'Update' }, - { value: 'upload', name: 'Upload' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['folder'] - } - }, - options: [ - { value: 'create', name: 'Create' }, - { value: 'delete', name: 'Delete' }, - { value: 'share', name: 'Share' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['fileFolder'] - } - }, - options: [ - { value: 'search', name: 'Search' } - ] - } - ], - operations: [], - credentials: [] - }; - - repository.saveNode(googleDriveNode); - - // Add Slack test node - const slackNode = { - nodeType: 'nodes-base.slack', - packageName: 'n8n-nodes-base', - displayName: 'Slack', - description: 'Send messages to Slack', - category: 'communication', - style: 'declarative' as const, - isAITool: false, - isTrigger: false, - isWebhook: false, - isVersioned: true, - version: '2', - properties: [ - { - name: 'resource', - type: 'options', - required: true, - options: [ - { value: 'channel', name: 'Channel' }, - { value: 'message', name: 'Message' }, - { value: 'user', name: 'User' } - ] - }, - { - name: 'operation', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'] - } - }, - options: [ - { value: 'send', name: 'Send' }, - { value: 'update', name: 'Update' }, - { value: 'delete', name: 'Delete' } - ] - } - ], - operations: [], - credentials: [] - }; - - repository.saveNode(slackNode); - }); - - afterEach(async () => { - // Clean up database - if (testDb) { - await testDb.cleanup(); - } - }); - - describe('Invalid Operations', () => { - it('should detect invalid operation "listFiles" for Google Drive', () => { - const config = { - resource: 'fileFolder', - operation: 'listFiles' - }; - - const node = repository.getNode('nodes-base.googleDrive'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleDrive', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - - // Should have an error for invalid operation - const operationError = result.errors.find(e => e.property === 'operation'); - expect(operationError).toBeDefined(); - expect(operationError!.message).toContain('Invalid operation "listFiles"'); - expect(operationError!.message).toContain('Did you mean'); - expect(operationError!.fix).toContain('search'); // Should suggest 'search' for fileFolder resource - }); - - it('should provide suggestions for typos in operations', () => { - const config = { - resource: 'file', - operation: 'downlod' // Typo: missing 'a' - }; - - const node = repository.getNode('nodes-base.googleDrive'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleDrive', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - - const operationError = result.errors.find(e => e.property === 'operation'); - expect(operationError).toBeDefined(); - expect(operationError!.message).toContain('Did you mean "download"'); - }); - - it('should list valid operations for the resource', () => { - const config = { - resource: 'folder', - operation: 'upload' // Invalid for folder resource - }; - - const node = repository.getNode('nodes-base.googleDrive'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleDrive', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - - const operationError = result.errors.find(e => e.property === 'operation'); - expect(operationError).toBeDefined(); - expect(operationError!.fix).toContain('Valid operations for resource "folder"'); - expect(operationError!.fix).toContain('create'); - expect(operationError!.fix).toContain('delete'); - expect(operationError!.fix).toContain('share'); - }); - }); - - describe('Invalid Resources', () => { - it('should detect plural resource "files" and suggest singular', () => { - const config = { - resource: 'files', // Should be 'file' - operation: 'list' - }; - - const node = repository.getNode('nodes-base.googleDrive'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleDrive', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - - const resourceError = result.errors.find(e => e.property === 'resource'); - expect(resourceError).toBeDefined(); - expect(resourceError!.message).toContain('Invalid resource "files"'); - expect(resourceError!.message).toContain('Did you mean "file"'); - expect(resourceError!.fix).toContain('Use singular'); - }); - - it('should suggest similar resources for typos', () => { - const config = { - resource: 'flie', // Typo - operation: 'download' - }; - - const node = repository.getNode('nodes-base.googleDrive'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleDrive', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - - const resourceError = result.errors.find(e => e.property === 'resource'); - expect(resourceError).toBeDefined(); - expect(resourceError!.message).toContain('Did you mean "file"'); - }); - - it('should list valid resources when no match found', () => { - const config = { - resource: 'document', // Not a valid resource - operation: 'create' - }; - - const node = repository.getNode('nodes-base.googleDrive'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleDrive', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - - const resourceError = result.errors.find(e => e.property === 'resource'); - expect(resourceError).toBeDefined(); - expect(resourceError!.fix).toContain('Valid resources:'); - expect(resourceError!.fix).toContain('file'); - expect(resourceError!.fix).toContain('folder'); - }); - }); - - describe('Combined Resource and Operation Validation', () => { - it('should validate both resource and operation together', () => { - const config = { - resource: 'files', // Invalid: should be singular - operation: 'listFiles' // Invalid: should be 'list' or 'search' - }; - - const node = repository.getNode('nodes-base.googleDrive'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleDrive', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - expect(result.errors.length).toBeGreaterThanOrEqual(2); - - // Should have error for resource - const resourceError = result.errors.find(e => e.property === 'resource'); - expect(resourceError).toBeDefined(); - expect(resourceError!.message).toContain('files'); - - // Should have error for operation - const operationError = result.errors.find(e => e.property === 'operation'); - expect(operationError).toBeDefined(); - expect(operationError!.message).toContain('listFiles'); - }); - }); - - describe('Slack Node Validation', () => { - it('should suggest "send" instead of "sendMessage"', () => { - const config = { - resource: 'message', - operation: 'sendMessage' // Common mistake - }; - - const node = repository.getNode('nodes-base.slack'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - - const operationError = result.errors.find(e => e.property === 'operation'); - expect(operationError).toBeDefined(); - expect(operationError!.message).toContain('Did you mean "send"'); - }); - - it('should suggest singular "channel" instead of "channels"', () => { - const config = { - resource: 'channels', // Should be singular - operation: 'create' - }; - - const node = repository.getNode('nodes-base.slack'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - - const resourceError = result.errors.find(e => e.property === 'resource'); - expect(resourceError).toBeDefined(); - expect(resourceError!.message).toContain('Did you mean "channel"'); - }); - }); - - describe('Valid Configurations', () => { - it('should accept valid Google Drive configuration', () => { - const config = { - resource: 'file', - operation: 'download' - }; - - const node = repository.getNode('nodes-base.googleDrive'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleDrive', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - // Should not have errors for resource or operation - const resourceError = result.errors.find(e => e.property === 'resource'); - const operationError = result.errors.find(e => e.property === 'operation'); - expect(resourceError).toBeUndefined(); - expect(operationError).toBeUndefined(); - }); - - it('should accept valid Slack configuration', () => { - const config = { - resource: 'message', - operation: 'send' - }; - - const node = repository.getNode('nodes-base.slack'); - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.slack', - config, - node.properties, - 'operation', - 'ai-friendly' - ); - - // Should not have errors for resource or operation - const resourceError = result.errors.find(e => e.property === 'resource'); - const operationError = result.errors.find(e => e.property === 'operation'); - expect(resourceError).toBeUndefined(); - expect(operationError).toBeUndefined(); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/enhanced-config-validator-type-structures.test.ts b/tests/unit/services/enhanced-config-validator-type-structures.test.ts deleted file mode 100644 index 9d13ef6..0000000 --- a/tests/unit/services/enhanced-config-validator-type-structures.test.ts +++ /dev/null @@ -1,684 +0,0 @@ -/** - * Tests for EnhancedConfigValidator - Type Structure Validation - * - * Tests the integration of TypeStructureService into EnhancedConfigValidator - * for validating complex types: filter, resourceMapper, assignmentCollection, resourceLocator - * - * @group unit - * @group services - * @group validation - */ - -import { describe, it, expect } from 'vitest'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -describe('EnhancedConfigValidator - Type Structure Validation', () => { - describe('Filter Type Validation', () => { - it('should validate valid filter configuration', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - leftValue: '{{ $json.name }}', - operator: { type: 'string', operation: 'equals' }, - rightValue: 'John', - }, - ], - }, - }; - const properties = [ - { - name: 'conditions', - type: 'filter', - required: true, - displayName: 'Conditions', - default: {}, - }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it('should validate filter with multiple conditions', () => { - const config = { - conditions: { - combinator: 'or', - conditions: [ - { - id: '1', - leftValue: '{{ $json.age }}', - operator: { type: 'number', operation: 'gt' }, - rightValue: 18, - }, - { - id: '2', - leftValue: '{{ $json.country }}', - operator: { type: 'string', operation: 'equals' }, - rightValue: 'US', - }, - ], - }, - }; - const properties = [ - { name: 'conditions', type: 'filter', required: true }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - - it('should detect missing combinator in filter', () => { - const config = { - conditions: { - conditions: [ - { - id: '1', - operator: { type: 'string', operation: 'equals' }, - leftValue: 'test', - rightValue: 'value', - }, - ], - // Missing combinator - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - expect(result.errors).toContainEqual( - expect.objectContaining({ - property: expect.stringMatching(/conditions/), - type: 'invalid_configuration', - }) - ); - }); - - it('should detect invalid combinator value', () => { - const config = { - conditions: { - combinator: 'invalid', // Should be 'and' or 'or' - conditions: [ - { - id: '1', - operator: { type: 'string', operation: 'equals' }, - leftValue: 'test', - rightValue: 'value', - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - }); - }); - - describe('Filter Operation Validation', () => { - it('should validate string operations correctly', () => { - const validOperations = [ - 'equals', - 'notEquals', - 'contains', - 'notContains', - 'startsWith', - 'endsWith', - 'regex', - ]; - - for (const operation of validOperations) { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'string', operation }, - leftValue: 'test', - rightValue: 'value', - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - } - }); - - it('should reject invalid operation for string type', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'string', operation: 'gt' }, // 'gt' is for numbers - leftValue: 'test', - rightValue: 'value', - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - expect(result.errors).toContainEqual( - expect.objectContaining({ - property: expect.stringContaining('operator.operation'), - message: expect.stringContaining('not valid for type'), - }) - ); - }); - - it('should validate number operations correctly', () => { - const validOperations = ['equals', 'notEquals', 'gt', 'lt', 'gte', 'lte']; - - for (const operation of validOperations) { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'number', operation }, - leftValue: 10, - rightValue: 20, - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - } - }); - - it('should reject string operations for number type', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'number', operation: 'contains' }, // 'contains' is for strings - leftValue: 10, - rightValue: 20, - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - }); - - it('should validate boolean operations', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'boolean', operation: 'true' }, - leftValue: '{{ $json.isActive }}', - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - - it('should validate dateTime operations', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'dateTime', operation: 'after' }, - leftValue: '{{ $json.createdAt }}', - rightValue: '2024-01-01', - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - - it('should validate array operations', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'array', operation: 'contains' }, - leftValue: '{{ $json.tags }}', - rightValue: 'urgent', - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - }); - - describe('ResourceMapper Type Validation', () => { - it('should validate valid resourceMapper configuration', () => { - const config = { - mapping: { - mappingMode: 'defineBelow', - value: { - name: '{{ $json.fullName }}', - email: '{{ $json.emailAddress }}', - status: 'active', - }, - }, - }; - const properties = [ - { name: 'mapping', type: 'resourceMapper', required: true }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.httpRequest', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - - it('should validate autoMapInputData mode', () => { - const config = { - mapping: { - mappingMode: 'autoMapInputData', - value: {}, - }, - }; - const properties = [ - { name: 'mapping', type: 'resourceMapper', required: true }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.httpRequest', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - }); - - describe('AssignmentCollection Type Validation', () => { - it('should validate valid assignmentCollection configuration', () => { - const config = { - assignments: { - assignments: [ - { - id: '1', - name: 'userName', - value: '{{ $json.name }}', - type: 'string', - }, - { - id: '2', - name: 'userAge', - value: 30, - type: 'number', - }, - ], - }, - }; - const properties = [ - { name: 'assignments', type: 'assignmentCollection', required: true }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.set', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - - it('should detect missing assignments array', () => { - const config = { - assignments: { - // Missing assignments array - }, - }; - const properties = [ - { name: 'assignments', type: 'assignmentCollection', required: true }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.set', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(false); - }); - }); - - describe('ResourceLocator Type Validation', () => { - // TODO: Debug why resourceLocator tests fail - issue appears to be with base validator, not the new validation logic - it.skip('should validate valid resourceLocator by ID', () => { - const config = { - resource: { - mode: 'id', - value: 'abc123', - }, - }; - const properties = [ - { - name: 'resource', - type: 'resourceLocator', - required: true, - displayName: 'Resource', - default: { mode: 'list', value: '' }, - }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleSheets', - config, - properties, - 'operation', - 'ai-friendly' - ); - - if (!result.valid) { - console.log('DEBUG - ResourceLocator validation failed:'); - console.log('Errors:', JSON.stringify(result.errors, null, 2)); - } - - expect(result.valid).toBe(true); - }); - - it.skip('should validate resourceLocator by URL', () => { - const config = { - resource: { - mode: 'url', - value: 'https://example.com/resource/123', - }, - }; - const properties = [ - { - name: 'resource', - type: 'resourceLocator', - required: true, - displayName: 'Resource', - default: { mode: 'list', value: '' }, - }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleSheets', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - - it.skip('should validate resourceLocator by list', () => { - const config = { - resource: { - mode: 'list', - value: 'item-from-dropdown', - }, - }; - const properties = [ - { - name: 'resource', - type: 'resourceLocator', - required: true, - displayName: 'Resource', - default: { mode: 'list', value: '' }, - }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.googleSheets', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - }); - - describe('Edge Cases', () => { - it('should handle null values gracefully', () => { - const config = { - conditions: null, - }; - const properties = [{ name: 'conditions', type: 'filter', required: false }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - // Null is acceptable for non-required fields - expect(result.valid).toBe(true); - }); - - it('should handle undefined values gracefully', () => { - const config = {}; - const properties = [{ name: 'conditions', type: 'filter', required: false }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - - it('should handle multiple special types in same config', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'string', operation: 'equals' }, - leftValue: 'test', - rightValue: 'value', - }, - ], - }, - assignments: { - assignments: [ - { - id: '1', - name: 'result', - value: 'processed', - type: 'string', - }, - ], - }, - }; - const properties = [ - { name: 'conditions', type: 'filter', required: true }, - { name: 'assignments', type: 'assignmentCollection', required: true }, - ]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.custom', - config, - properties, - 'operation', - 'ai-friendly' - ); - - expect(result.valid).toBe(true); - }); - }); - - describe('Validation Profiles', () => { - it('should respect strict profile for type validation', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [ - { - id: '1', - operator: { type: 'string', operation: 'gt' }, // Invalid operation - leftValue: 'test', - rightValue: 'value', - }, - ], - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'strict' - ); - - expect(result.valid).toBe(false); - expect(result.profile).toBe('strict'); - }); - - it('should respect minimal profile (less strict)', () => { - const config = { - conditions: { - combinator: 'and', - conditions: [], // Empty but valid - }, - }; - const properties = [{ name: 'conditions', type: 'filter', required: true }]; - - const result = EnhancedConfigValidator.validateWithMode( - 'nodes-base.filter', - config, - properties, - 'operation', - 'minimal' - ); - - expect(result.profile).toBe('minimal'); - }); - }); -}); diff --git a/tests/unit/services/enhanced-config-validator.test.ts b/tests/unit/services/enhanced-config-validator.test.ts index ac667cf..6b2b659 100644 --- a/tests/unit/services/enhanced-config-validator.test.ts +++ b/tests/unit/services/enhanced-config-validator.test.ts @@ -2,7 +2,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EnhancedConfigValidator, ValidationMode, ValidationProfile } from '@/services/enhanced-config-validator'; import { ValidationError } from '@/services/config-validator'; import { NodeSpecificValidators } from '@/services/node-specific-validators'; +import { ResourceSimilarityService } from '@/services/resource-similarity-service'; +import { OperationSimilarityService } from '@/services/operation-similarity-service'; +import { NodeRepository } from '@/database/node-repository'; import { nodeFactory } from '@tests/fixtures/factories/node.factory'; +import { createTestDatabase } from '@tests/utils/database-utils'; + +// Mock similarity services +vi.mock('@/services/resource-similarity-service'); +vi.mock('@/services/operation-similarity-service'); // Mock node-specific validators vi.mock('@/services/node-specific-validators', () => ({ @@ -15,7 +23,8 @@ vi.mock('@/services/node-specific-validators', () => ({ validateWebhook: vi.fn(), validatePostgres: vi.fn(), validateMySQL: vi.fn(), - validateAIAgent: vi.fn() + validateAIAgent: vi.fn(), + validateSet: vi.fn() } })); @@ -1168,4 +1177,506 @@ describe('EnhancedConfigValidator', () => { }); }); }); + + // ─── Type Structure Validation (from enhanced-config-validator-type-structures) ─── + + describe('type structure validation', () => { + describe('Filter Type Validation', () => { + it('should validate valid filter configuration', () => { + const config = { + conditions: { + combinator: 'and', + conditions: [{ id: '1', leftValue: '{{ $json.name }}', operator: { type: 'string', operation: 'equals' }, rightValue: 'John' }], + }, + }; + const properties = [{ name: 'conditions', type: 'filter', required: true, displayName: 'Conditions', default: {} }]; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should validate filter with multiple conditions', () => { + const config = { + conditions: { + combinator: 'or', + conditions: [ + { id: '1', leftValue: '{{ $json.age }}', operator: { type: 'number', operation: 'gt' }, rightValue: 18 }, + { id: '2', leftValue: '{{ $json.country }}', operator: { type: 'string', operation: 'equals' }, rightValue: 'US' }, + ], + }, + }; + const properties = [{ name: 'conditions', type: 'filter', required: true }]; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it('should detect missing combinator in filter', () => { + const config = { + conditions: { + conditions: [{ id: '1', operator: { type: 'string', operation: 'equals' }, leftValue: 'test', rightValue: 'value' }], + }, + }; + const properties = [{ name: 'conditions', type: 'filter', required: true }]; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.objectContaining({ property: expect.stringMatching(/conditions/), type: 'invalid_configuration' })); + }); + + it('should detect invalid combinator value', () => { + const config = { + conditions: { + combinator: 'invalid', + conditions: [{ id: '1', operator: { type: 'string', operation: 'equals' }, leftValue: 'test', rightValue: 'value' }], + }, + }; + const properties = [{ name: 'conditions', type: 'filter', required: true }]; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + }); + }); + + describe('Filter Operation Validation', () => { + it('should validate string operations correctly', () => { + for (const operation of ['equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith', 'regex']) { + const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'string', operation }, leftValue: 'test', rightValue: 'value' }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + } + }); + + it('should reject invalid operation for string type', () => { + const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'string', operation: 'gt' }, leftValue: 'test', rightValue: 'value' }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual(expect.objectContaining({ property: expect.stringContaining('operator.operation'), message: expect.stringContaining('not valid for type') })); + }); + + it('should validate number operations correctly', () => { + for (const operation of ['equals', 'notEquals', 'gt', 'lt', 'gte', 'lte']) { + const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'number', operation }, leftValue: 10, rightValue: 20 }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + } + }); + + it('should reject string operations for number type', () => { + const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'number', operation: 'contains' }, leftValue: 10, rightValue: 20 }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + }); + + it('should validate boolean operations', () => { + const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'boolean', operation: 'true' }, leftValue: '{{ $json.isActive }}' }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it('should validate dateTime operations', () => { + const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'dateTime', operation: 'after' }, leftValue: '{{ $json.createdAt }}', rightValue: '2024-01-01' }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it('should validate array operations', () => { + const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'array', operation: 'contains' }, leftValue: '{{ $json.tags }}', rightValue: 'urgent' }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + }); + + describe('ResourceMapper Type Validation', () => { + it('should validate valid resourceMapper configuration', () => { + const config = { mapping: { mappingMode: 'defineBelow', value: { name: '{{ $json.fullName }}', email: '{{ $json.emailAddress }}', status: 'active' } } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.httpRequest', config, [{ name: 'mapping', type: 'resourceMapper', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it('should validate autoMapInputData mode', () => { + const config = { mapping: { mappingMode: 'autoMapInputData', value: {} } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.httpRequest', config, [{ name: 'mapping', type: 'resourceMapper', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + }); + + describe('AssignmentCollection Type Validation', () => { + it('should validate valid assignmentCollection configuration', () => { + const config = { assignments: { assignments: [{ id: '1', name: 'userName', value: '{{ $json.name }}', type: 'string' }, { id: '2', name: 'userAge', value: 30, type: 'number' }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.set', config, [{ name: 'assignments', type: 'assignmentCollection', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it('should detect missing assignments array', () => { + const config = { assignments: {} }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.set', config, [{ name: 'assignments', type: 'assignmentCollection', required: true }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + }); + }); + + describe('ResourceLocator Type Validation', () => { + it.skip('should validate valid resourceLocator by ID', () => { + const config = { resource: { mode: 'id', value: 'abc123' } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleSheets', config, [{ name: 'resource', type: 'resourceLocator', required: true, displayName: 'Resource', default: { mode: 'list', value: '' } }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it.skip('should validate resourceLocator by URL', () => { + const config = { resource: { mode: 'url', value: 'https://example.com/resource/123' } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleSheets', config, [{ name: 'resource', type: 'resourceLocator', required: true, displayName: 'Resource', default: { mode: 'list', value: '' } }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it.skip('should validate resourceLocator by list', () => { + const config = { resource: { mode: 'list', value: 'item-from-dropdown' } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleSheets', config, [{ name: 'resource', type: 'resourceLocator', required: true, displayName: 'Resource', default: { mode: 'list', value: '' } }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + }); + + describe('Type Structure Edge Cases', () => { + it('should handle null values gracefully', () => { + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', { conditions: null }, [{ name: 'conditions', type: 'filter', required: false }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it('should handle undefined values gracefully', () => { + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', {}, [{ name: 'conditions', type: 'filter', required: false }], 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + + it('should handle multiple special types in same config', () => { + const config = { + conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'string', operation: 'equals' }, leftValue: 'test', rightValue: 'value' }] }, + assignments: { assignments: [{ id: '1', name: 'result', value: 'processed', type: 'string' }] }, + }; + const properties = [{ name: 'conditions', type: 'filter', required: true }, { name: 'assignments', type: 'assignmentCollection', required: true }]; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.custom', config, properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(true); + }); + }); + + describe('Validation Profiles for Type Structures', () => { + it('should respect strict profile for type validation', () => { + const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'string', operation: 'gt' }, leftValue: 'test', rightValue: 'value' }] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'strict'); + expect(result.valid).toBe(false); + expect(result.profile).toBe('strict'); + }); + + it('should respect minimal profile (less strict)', () => { + const config = { conditions: { combinator: 'and', conditions: [] } }; + const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'minimal'); + expect(result.profile).toBe('minimal'); + }); + }); + }); +}); + +// ─── Integration Tests (from enhanced-config-validator-integration) ───────── + +describe('EnhancedConfigValidator - Integration Tests', () => { + let mockResourceService: any; + let mockOperationService: any; + let mockRepository: any; + + beforeEach(() => { + mockRepository = { + getNode: vi.fn(), + getNodeOperations: vi.fn().mockReturnValue([]), + getNodeResources: vi.fn().mockReturnValue([]), + getOperationsForResource: vi.fn().mockReturnValue([]), + getDefaultOperationForResource: vi.fn().mockReturnValue(undefined), + getNodePropertyDefaults: vi.fn().mockReturnValue({}) + }; + + mockResourceService = { findSimilarResources: vi.fn().mockReturnValue([]) }; + mockOperationService = { findSimilarOperations: vi.fn().mockReturnValue([]) }; + + vi.mocked(ResourceSimilarityService).mockImplementation(() => mockResourceService); + vi.mocked(OperationSimilarityService).mockImplementation(() => mockOperationService); + + EnhancedConfigValidator.initializeSimilarityServices(mockRepository); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('similarity service integration', () => { + it('should initialize similarity services when initializeSimilarityServices is called', () => { + expect(ResourceSimilarityService).toHaveBeenCalled(); + expect(OperationSimilarityService).toHaveBeenCalled(); + }); + + it('should use resource similarity service for invalid resource errors', () => { + mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.8, reason: 'Similar resource name', availableOperations: ['send', 'update'] }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource', operation: 'send' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }] }], 'operation', 'ai-friendly'); + expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith('nodes-base.slack', 'invalidResource', expect.any(Number)); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + it('should use operation similarity service for invalid operation errors', () => { + mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.9, reason: 'Very similar - likely a typo', resource: 'message' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'invalidOperation' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' }] }], 'operation', 'ai-friendly'); + expect(mockOperationService.findSimilarOperations).toHaveBeenCalledWith('nodes-base.slack', 'invalidOperation', 'message', expect.any(Number)); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + it('should handle similarity service errors gracefully', () => { + mockResourceService.findSimilarResources.mockImplementation(() => { throw new Error('Service error'); }); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource', operation: 'send' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly'); + expect(result).toBeDefined(); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should not call similarity services for valid configurations', () => { + mockRepository.getNodeResources.mockReturnValue([{ value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' }]); + mockRepository.getNodeOperations.mockReturnValue([{ value: 'send', name: 'Send Message' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'send', channel: '#general', text: 'Test message' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }] }], 'operation', 'ai-friendly'); + expect(mockResourceService.findSimilarResources).not.toHaveBeenCalled(); + expect(mockOperationService.findSimilarOperations).not.toHaveBeenCalled(); + expect(result.valid).toBe(true); + }); + + it('should limit suggestion count when calling similarity services', () => { + EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly'); + expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith('nodes-base.slack', 'invalidResource', 3); + }); + }); + + describe('error enhancement with suggestions', () => { + it('should enhance resource validation errors with suggestions', () => { + mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.85, reason: 'Very similar - likely a typo', availableOperations: ['send', 'update', 'delete'] }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'msgs' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' }] }], 'operation', 'ai-friendly'); + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.suggestion).toBeDefined(); + expect(resourceError!.suggestion).toContain('message'); + }); + + it('should enhance operation validation errors with suggestions', () => { + mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.9, reason: 'Almost exact match - likely a typo', resource: 'message', description: 'Send Message' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'sned' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' }] }], 'operation', 'ai-friendly'); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.suggestion).toBeDefined(); + expect(operationError!.suggestion).toContain('send'); + }); + + it('should not enhance errors when no good suggestions are available', () => { + mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.2, reason: 'Possibly related resource' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'completelyWrongValue' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly'); + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.suggestion).toBeUndefined(); + }); + + it('should provide multiple operation suggestions when resource is known', () => { + mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.7, reason: 'Similar operation' }, { value: 'update', confidence: 0.6, reason: 'Similar operation' }, { value: 'delete', confidence: 0.5, reason: 'Similar operation' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'invalidOp' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' }, { value: 'delete', name: 'Delete Message' }] }], 'operation', 'ai-friendly'); + expect(result.suggestions.length).toBeGreaterThan(2); + expect(result.suggestions.filter(s => s.includes('send') || s.includes('update') || s.includes('delete')).length).toBeGreaterThan(0); + }); + }); + + describe('confidence thresholds and filtering', () => { + it('should only use high confidence resource suggestions', () => { + mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message1', confidence: 0.9, reason: 'High confidence' }, { value: 'message2', confidence: 0.4, reason: 'Low confidence' }, { value: 'message3', confidence: 0.7, reason: 'Medium confidence' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly'); + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError?.suggestion).toBeDefined(); + expect(resourceError!.suggestion).toContain('message1'); + }); + + it('should only use high confidence operation suggestions', () => { + mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.95, reason: 'Very high confidence' }, { value: 'post', confidence: 0.3, reason: 'Low confidence' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'invalidOperation' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }] }], 'operation', 'ai-friendly'); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError?.suggestion).toBeDefined(); + expect(operationError!.suggestion).toContain('send'); + expect(operationError!.suggestion).not.toContain('post'); + }); + }); + + describe('integration with existing validation logic', () => { + it('should work with minimal validation mode', () => { + mockRepository.getNodeResources.mockReturnValue([]); + mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.8, reason: 'Similar' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'minimal', 'ai-friendly'); + expect(mockResourceService.findSimilarResources).toHaveBeenCalled(); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should work with strict validation profile', () => { + mockRepository.getNodeResources.mockReturnValue([{ value: 'message', name: 'Message' }]); + mockRepository.getOperationsForResource.mockReturnValue([]); + mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.8, reason: 'Similar' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'invalidOp' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }] }], 'operation', 'strict'); + expect(mockOperationService.findSimilarOperations).toHaveBeenCalled(); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError?.suggestion).toBeDefined(); + }); + + it('should preserve original error properties when enhancing', () => { + mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.8, reason: 'Similar' }]); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly'); + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError?.type).toBeDefined(); + expect(resourceError?.property).toBe('resource'); + expect(resourceError?.message).toBeDefined(); + expect(resourceError?.suggestion).toBeDefined(); + }); + }); +}); + +// ─── Operation and Resource Validation (from enhanced-config-validator-operations) ─── + +describe('EnhancedConfigValidator - Operation and Resource Validation', () => { + let repository: NodeRepository; + let testDb: any; + + beforeEach(async () => { + testDb = await createTestDatabase(); + repository = testDb.nodeRepository; + + // Configure mocked similarity services to return empty arrays by default + vi.mocked(ResourceSimilarityService).mockImplementation(() => ({ + findSimilarResources: vi.fn().mockReturnValue([]) + }) as any); + vi.mocked(OperationSimilarityService).mockImplementation(() => ({ + findSimilarOperations: vi.fn().mockReturnValue([]) + }) as any); + + EnhancedConfigValidator.initializeSimilarityServices(repository); + + repository.saveNode({ + nodeType: 'nodes-base.googleDrive', packageName: 'n8n-nodes-base', displayName: 'Google Drive', description: 'Access Google Drive', category: 'transform', style: 'declarative' as const, isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1', + properties: [ + { name: 'resource', type: 'options', required: true, options: [{ value: 'file', name: 'File' }, { value: 'folder', name: 'Folder' }, { value: 'fileFolder', name: 'File & Folder' }] }, + { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['file'] } }, options: [{ value: 'copy', name: 'Copy' }, { value: 'delete', name: 'Delete' }, { value: 'download', name: 'Download' }, { value: 'list', name: 'List' }, { value: 'share', name: 'Share' }, { value: 'update', name: 'Update' }, { value: 'upload', name: 'Upload' }] }, + { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['folder'] } }, options: [{ value: 'create', name: 'Create' }, { value: 'delete', name: 'Delete' }, { value: 'share', name: 'Share' }] }, + { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['fileFolder'] } }, options: [{ value: 'search', name: 'Search' }] } + ], + operations: [], credentials: [] + }); + + repository.saveNode({ + nodeType: 'nodes-base.slack', packageName: 'n8n-nodes-base', displayName: 'Slack', description: 'Send messages to Slack', category: 'communication', style: 'declarative' as const, isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '2', + properties: [ + { name: 'resource', type: 'options', required: true, options: [{ value: 'channel', name: 'Channel' }, { value: 'message', name: 'Message' }, { value: 'user', name: 'User' }] }, + { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send' }, { value: 'update', name: 'Update' }, { value: 'delete', name: 'Delete' }] } + ], + operations: [], credentials: [] + }); + }); + + afterEach(async () => { + if (testDb) { await testDb.cleanup(); } + }); + + describe('Invalid Operations', () => { + it('should detect invalid operation for Google Drive fileFolder resource', () => { + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'fileFolder', operation: 'listFiles' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.message).toContain('listFiles'); + }); + + it('should detect typos in operations', () => { + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'file', operation: 'downlod' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + }); + + it('should list valid operations for the resource', () => { + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'folder', operation: 'upload' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.fix).toContain('Valid operations for resource "folder"'); + expect(operationError!.fix).toContain('create'); + expect(operationError!.fix).toContain('delete'); + expect(operationError!.fix).toContain('share'); + }); + }); + + describe('Invalid Resources', () => { + it('should detect invalid plural resource "files"', () => { + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'files', operation: 'list' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.message).toContain('files'); + }); + + it('should detect typos in resources', () => { + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'flie', operation: 'download' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + }); + + it('should list valid resources when no match found', () => { + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'document', operation: 'create' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.fix).toContain('Valid resources:'); + expect(resourceError!.fix).toContain('file'); + expect(resourceError!.fix).toContain('folder'); + }); + }); + + describe('Combined Resource and Operation Validation', () => { + it('should validate both resource and operation together', () => { + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'files', operation: 'listFiles' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(2); + expect(result.errors.find(e => e.property === 'resource')).toBeDefined(); + expect(result.errors.find(e => e.property === 'operation')).toBeDefined(); + }); + }); + + describe('Slack Node Validation', () => { + it('should detect invalid operation "sendMessage" for Slack', () => { + const node = repository.getNode('nodes-base.slack'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'sendMessage' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + }); + + it('should detect invalid plural resource "channels" for Slack', () => { + const node = repository.getNode('nodes-base.slack'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'channels', operation: 'create' }, node.properties, 'operation', 'ai-friendly'); + expect(result.valid).toBe(false); + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + }); + }); + + describe('Valid Configurations', () => { + it('should accept valid Google Drive configuration', () => { + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'file', operation: 'download' }, node.properties, 'operation', 'ai-friendly'); + expect(result.errors.find(e => e.property === 'resource')).toBeUndefined(); + expect(result.errors.find(e => e.property === 'operation')).toBeUndefined(); + }); + + it('should accept valid Slack configuration', () => { + const node = repository.getNode('nodes-base.slack'); + const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'send' }, node.properties, 'operation', 'ai-friendly'); + expect(result.errors.find(e => e.property === 'resource')).toBeUndefined(); + expect(result.errors.find(e => e.property === 'operation')).toBeUndefined(); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/services/loop-output-edge-cases.test.ts b/tests/unit/services/loop-output-edge-cases.test.ts deleted file mode 100644 index 85916cc..0000000 --- a/tests/unit/services/loop-output-edge-cases.test.ts +++ /dev/null @@ -1,865 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -// Mock dependencies -vi.mock('@/database/node-repository'); -vi.mock('@/services/enhanced-config-validator'); - -describe('Loop Output Fix - Edge Cases', () => { - let validator: WorkflowValidator; - let mockNodeRepository: any; - let mockNodeValidator: any; - - beforeEach(() => { - vi.clearAllMocks(); - - mockNodeRepository = { - getNode: vi.fn((nodeType: string) => { - // Default return - if (nodeType === 'nodes-base.splitInBatches') { - return { - nodeType: 'nodes-base.splitInBatches', - outputs: [ - { displayName: 'Done', name: 'done' }, - { displayName: 'Loop', name: 'loop' } - ], - outputNames: ['done', 'loop'], - properties: [] - }; - } - return { - nodeType, - properties: [] - }; - }) - }; - - mockNodeValidator = { - validateWithMode: vi.fn().mockReturnValue({ - errors: [], - warnings: [] - }) - }; - - validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator); - }); - - describe('Nodes without outputs', () => { - it('should handle nodes with null outputs gracefully', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.httpRequest', - outputs: null, - outputNames: null, - properties: [] - }); - - const workflow = { - name: 'No Outputs Workflow', - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: { url: 'https://example.com' } - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'HTTP Request': { - main: [ - [{ node: 'Set', type: 'main', index: 0 }] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not crash or produce output-related errors - expect(result).toBeDefined(); - const outputErrors = result.errors.filter(e => - e.message?.includes('output') && !e.message?.includes('Connection') - ); - expect(outputErrors).toHaveLength(0); - }); - - it('should handle nodes with undefined outputs gracefully', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.webhook', - // outputs and outputNames are undefined - properties: [] - }); - - const workflow = { - name: 'Undefined Outputs Workflow', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result).toBeDefined(); - expect(result.valid).toBeTruthy(); // Empty workflow with webhook should be valid - }); - - it('should handle nodes with empty outputs array', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.customNode', - outputs: [], - outputNames: [], - properties: [] - }); - - const workflow = { - name: 'Empty Outputs Workflow', - nodes: [ - { - id: '1', - name: 'Custom Node', - type: 'n8n-nodes-base.customNode', - position: [100, 100], - parameters: {} - } - ], - connections: { - 'Custom Node': { - main: [ - [{ node: 'Custom Node', type: 'main', index: 0 }] // Self-reference - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should warn about self-reference but not crash - const selfRefWarnings = result.warnings.filter(w => - w.message?.includes('self-referencing') - ); - expect(selfRefWarnings).toHaveLength(1); - }); - }); - - describe('Invalid connection indices', () => { - it('should handle negative connection indices', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Negative Index Workflow', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [{ node: 'Set', type: 'main', index: -1 }] // Invalid negative index - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - const negativeIndexErrors = result.errors.filter(e => - e.message?.includes('Invalid connection index -1') - ); - expect(negativeIndexErrors).toHaveLength(1); - expect(negativeIndexErrors[0].message).toContain('must be non-negative'); - }); - - it('should handle very large connection indices', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.switch', - outputs: [ - { displayName: 'Output 1' }, - { displayName: 'Output 2' } - ], - properties: [] - }); - - const workflow = { - name: 'Large Index Workflow', - nodes: [ - { - id: '1', - name: 'Switch', - type: 'n8n-nodes-base.switch', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Switch': { - main: [ - [{ node: 'Set', type: 'main', index: 999 }] // Very large index - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should validate without crashing (n8n allows large indices) - expect(result).toBeDefined(); - }); - }); - - describe('Malformed connection structures', () => { - it('should handle null connection objects', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Null Connections Workflow', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - null, // Null output - [{ node: 'NonExistent', type: 'main', index: 0 }] - ] as any - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should handle gracefully without crashing - expect(result).toBeDefined(); - }); - - it('should handle missing connection properties', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Malformed Connections Workflow', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [ - { node: 'Set' } as any, // Missing type and index - { type: 'main', index: 0 } as any, // Missing node - {} as any // Empty object - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should handle malformed connections but report errors - expect(result).toBeDefined(); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('Deep loop back detection limits', () => { - it('should respect maxDepth limit in checkForLoopBack', async () => { - // Use default mock that includes outputs for SplitInBatches - - // Create a very deep chain that exceeds maxDepth (50) - const nodes = [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - } - ]; - - const connections: any = { - 'Split In Batches': { - main: [ - [], // Done output - [{ node: 'Node1', type: 'main', index: 0 }] // Loop output - ] - } - }; - - // Create chain of 60 nodes (exceeds maxDepth of 50) - for (let i = 1; i <= 60; i++) { - nodes.push({ - id: (i + 1).toString(), - name: `Node${i}`, - type: 'n8n-nodes-base.set', - position: [100 + i * 50, 100], - parameters: {} - }); - - if (i < 60) { - connections[`Node${i}`] = { - main: [[{ node: `Node${i + 1}`, type: 'main', index: 0 }]] - }; - } else { - // Last node connects back to Split In Batches - connections[`Node${i}`] = { - main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]] - }; - } - } - - const workflow = { - name: 'Deep Chain Workflow', - nodes, - connections - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should warn about missing loop back because depth limit prevents detection - const loopBackWarnings = result.warnings.filter(w => - w.message?.includes('doesn\'t connect back') - ); - expect(loopBackWarnings).toHaveLength(1); - }); - - it('should handle circular references without infinite loops', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Circular Reference Workflow', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'NodeA', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'NodeB', - type: 'n8n-nodes-base.function', - position: [500, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [], - [{ node: 'NodeA', type: 'main', index: 0 }] - ] - }, - 'NodeA': { - main: [ - [{ node: 'NodeB', type: 'main', index: 0 }] - ] - }, - 'NodeB': { - main: [ - [{ node: 'NodeA', type: 'main', index: 0 }] // Circular: B -> A -> B -> A ... - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should complete without hanging and warn about missing loop back - expect(result).toBeDefined(); - const loopBackWarnings = result.warnings.filter(w => - w.message?.includes('doesn\'t connect back') - ); - expect(loopBackWarnings).toHaveLength(1); - }); - - it('should handle self-referencing nodes in loop back detection', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Self Reference Workflow', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'SelfRef', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [], - [{ node: 'SelfRef', type: 'main', index: 0 }] - ] - }, - 'SelfRef': { - main: [ - [{ node: 'SelfRef', type: 'main', index: 0 }] // Self-reference instead of loop back - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should warn about missing loop back and self-reference - const loopBackWarnings = result.warnings.filter(w => - w.message?.includes('doesn\'t connect back') - ); - const selfRefWarnings = result.warnings.filter(w => - w.message?.includes('self-referencing') - ); - - expect(loopBackWarnings).toHaveLength(1); - expect(selfRefWarnings).toHaveLength(1); - }); - }); - - describe('Complex output structures', () => { - it('should handle nodes with many outputs', async () => { - const manyOutputs = Array.from({ length: 20 }, (_, i) => ({ - displayName: `Output ${i + 1}`, - name: `output${i + 1}`, - description: `Output number ${i + 1}` - })); - - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.complexSwitch', - outputs: manyOutputs, - outputNames: manyOutputs.map(o => o.name), - properties: [] - }); - - const workflow = { - name: 'Many Outputs Workflow', - nodes: [ - { - id: '1', - name: 'Complex Switch', - type: 'n8n-nodes-base.complexSwitch', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Complex Switch': { - main: Array.from({ length: 20 }, () => [ - { node: 'Set', type: 'main', index: 0 } - ]) - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should handle without performance issues - expect(result).toBeDefined(); - }); - - it('should handle mixed output types (main, error, ai_tool)', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.complexNode', - outputs: [ - { displayName: 'Main', type: 'main' }, - { displayName: 'Error', type: 'error' } - ], - properties: [] - }); - - const workflow = { - name: 'Mixed Output Types Workflow', - nodes: [ - { - id: '1', - name: 'Complex Node', - type: 'n8n-nodes-base.complexNode', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Main Handler', - type: 'n8n-nodes-base.set', - position: [300, 50], - parameters: {} - }, - { - id: '3', - name: 'Error Handler', - type: 'n8n-nodes-base.set', - position: [300, 150], - parameters: {} - }, - { - id: '4', - name: 'Tool', - type: 'n8n-nodes-base.httpRequest', - position: [500, 100], - parameters: {} - } - ], - connections: { - 'Complex Node': { - main: [ - [{ node: 'Main Handler', type: 'main', index: 0 }] - ], - error: [ - [{ node: 'Error Handler', type: 'main', index: 0 }] - ], - ai_tool: [ - [{ node: 'Tool', type: 'main', index: 0 }] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should validate all connection types - expect(result).toBeDefined(); - expect(result.statistics.validConnections).toBe(3); - }); - }); - - describe('SplitInBatches specific edge cases', () => { - it('should handle SplitInBatches with no connections', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Isolated SplitInBatches', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not produce SplitInBatches-specific warnings for isolated node - const splitWarnings = result.warnings.filter(w => - w.message?.includes('SplitInBatches') || - w.message?.includes('loop') || - w.message?.includes('done') - ); - expect(splitWarnings).toHaveLength(0); - }); - - it('should handle SplitInBatches with only one output connected', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Single Output SplitInBatches', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Final Action', - type: 'n8n-nodes-base.emailSend', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [{ node: 'Final Action', type: 'main', index: 0 }], // Only done output connected - [] // Loop output empty - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should NOT warn about empty loop output (it's only a problem if loop connects to something but doesn't loop back) - // An empty loop output is valid - it just means no looping occurs - const loopWarnings = result.warnings.filter(w => - w.message?.includes('loop') && w.message?.includes('connect back') - ); - expect(loopWarnings).toHaveLength(0); - }); - - it('should handle SplitInBatches with both outputs to same node', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Same Target SplitInBatches', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Multi Purpose', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [{ node: 'Multi Purpose', type: 'main', index: 0 }], // Done -> Multi Purpose - [{ node: 'Multi Purpose', type: 'main', index: 0 }] // Loop -> Multi Purpose - ] - }, - 'Multi Purpose': { - main: [ - [{ node: 'Split In Batches', type: 'main', index: 0 }] // Loop back - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Both outputs go to same node which loops back - should be valid - // No warnings about loop back since it does connect back - const loopWarnings = result.warnings.filter(w => - w.message?.includes('loop') && w.message?.includes('connect back') - ); - expect(loopWarnings).toHaveLength(0); - }); - - it('should detect reversed outputs with processing node on done output', async () => { - // Use default mock that includes outputs for SplitInBatches - - const workflow = { - name: 'Reversed SplitInBatches with Function Node', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Process Function', - type: 'n8n-nodes-base.function', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [{ node: 'Process Function', type: 'main', index: 0 }], // Done -> Function (this is wrong) - [] // Loop output empty - ] - }, - 'Process Function': { - main: [ - [{ node: 'Split In Batches', type: 'main', index: 0 }] // Function connects back (indicates it should be on loop) - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should error about reversed outputs since function node on done output connects back - const reversedErrors = result.errors.filter(e => - e.message?.includes('SplitInBatches outputs appear reversed') - ); - expect(reversedErrors).toHaveLength(1); - }); - - it('should handle non-existent node type gracefully', async () => { - // Node doesn't exist in repository - mockNodeRepository.getNode.mockReturnValue(null); - - const workflow = { - name: 'Unknown Node Type', - nodes: [ - { - id: '1', - name: 'Unknown Node', - type: 'n8n-nodes-base.unknownNode', - position: [100, 100], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should report unknown node type error - const unknownNodeErrors = result.errors.filter(e => - e.message?.includes('Unknown node type') - ); - expect(unknownNodeErrors).toHaveLength(1); - }); - }); - - describe('Performance edge cases', () => { - it('should handle very large workflows efficiently', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.set', - properties: [] - }); - - // Create workflow with 1000 nodes - const nodes = Array.from({ length: 1000 }, (_, i) => ({ - id: `node${i}`, - name: `Node ${i}`, - type: 'n8n-nodes-base.set', - position: [100 + (i % 50) * 50, 100 + Math.floor(i / 50) * 50], - parameters: {} - })); - - // Create simple linear connections - const connections: any = {}; - for (let i = 0; i < 999; i++) { - connections[`Node ${i}`] = { - main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]] - }; - } - - const workflow = { - name: 'Large Workflow', - nodes, - connections - }; - - const startTime = Date.now(); - const result = await validator.validateWorkflow(workflow as any); - const duration = Date.now() - startTime; - - // Should complete within reasonable time (< 5 seconds) - expect(duration).toBeLessThan(5000); - expect(result).toBeDefined(); - expect(result.statistics.totalNodes).toBe(1000); - }); - - it('should handle workflows with many SplitInBatches nodes', async () => { - // Use default mock that includes outputs for SplitInBatches - - // Create 100 SplitInBatches nodes - const nodes = Array.from({ length: 100 }, (_, i) => ({ - id: `split${i}`, - name: `Split ${i}`, - type: 'n8n-nodes-base.splitInBatches', - position: [100 + (i % 10) * 100, 100 + Math.floor(i / 10) * 100], - parameters: {} - })); - - const connections: any = {}; - // Each split connects to the next one - for (let i = 0; i < 99; i++) { - connections[`Split ${i}`] = { - main: [ - [{ node: `Split ${i + 1}`, type: 'main', index: 0 }], // Done -> next split - [] // Empty loop - ] - }; - } - - const workflow = { - name: 'Many SplitInBatches Workflow', - nodes, - connections - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should validate all nodes without performance issues - expect(result).toBeDefined(); - expect(result.statistics.totalNodes).toBe(100); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/n8n-validation-sticky-notes.test.ts b/tests/unit/services/n8n-validation-sticky-notes.test.ts deleted file mode 100644 index e7873d5..0000000 --- a/tests/unit/services/n8n-validation-sticky-notes.test.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import { validateWorkflowStructure } from '@/services/n8n-validation'; -import type { Workflow } from '@/types/n8n-api'; - -describe('n8n-validation - Sticky Notes Bug Fix', () => { - describe('sticky notes should be excluded from disconnected nodes validation', () => { - test('should allow workflow with sticky notes and connected functional nodes', () => { - const workflow: Partial = { - name: 'Test Workflow', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/test' } - }, - { - id: '2', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [450, 300], - parameters: {} - }, - { - id: 'sticky1', - name: 'Documentation Note', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [250, 100], - parameters: { content: 'This is a documentation note' } - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] - } - } - }; - - const errors = validateWorkflowStructure(workflow); - - // Should have no errors - sticky note should be ignored - expect(errors).toEqual([]); - }); - - test('should handle multiple sticky notes without errors', () => { - const workflow: Partial = { - name: 'Documented Workflow', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/test' } - }, - { - id: '2', - name: 'Process', - type: 'n8n-nodes-base.set', - typeVersion: 3, - position: [450, 300], - parameters: {} - }, - // 10 sticky notes for documentation - ...Array.from({ length: 10 }, (_, i) => ({ - id: `sticky${i}`, - name: `šŸ“ Note ${i}`, - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [100 + i * 50, 100] as [number, number], - parameters: { content: `Documentation note ${i}` } - })) - ], - connections: { - 'Webhook': { - main: [[{ node: 'Process', type: 'main', index: 0 }]] - } - } - }; - - const errors = validateWorkflowStructure(workflow); - expect(errors).toEqual([]); - }); - - test('should handle all sticky note type variations', () => { - const stickyTypes = [ - 'n8n-nodes-base.stickyNote', - 'nodes-base.stickyNote', - '@n8n/n8n-nodes-base.stickyNote' - ]; - - stickyTypes.forEach((stickyType, index) => { - const workflow: Partial = { - name: 'Test Workflow', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/test' } - }, - { - id: `sticky${index}`, - name: `Note ${index}`, - type: stickyType, - typeVersion: 1, - position: [250, 100], - parameters: { content: `Note ${index}` } - } - ], - connections: {} - }; - - const errors = validateWorkflowStructure(workflow); - - // Sticky note should be ignored regardless of type variation - expect(errors.every(e => !e.includes(`Note ${index}`))).toBe(true); - }); - }); - - test('should handle complex workflow with multiple sticky notes (real-world scenario)', () => { - // Simulates workflow like "POST /auth/login" with 4 sticky notes - const workflow: Partial = { - name: 'POST /auth/login', - nodes: [ - { - id: 'webhook1', - name: 'Webhook Trigger', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/auth/login', httpMethod: 'POST' } - }, - { - id: 'http1', - name: 'Authenticate', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [450, 300], - parameters: {} - }, - { - id: 'respond1', - name: 'Return Success', - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1, - position: [650, 250], - parameters: {} - }, - { - id: 'respond2', - name: 'Return Error', - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1, - position: [650, 350], - parameters: {} - }, - // 4 sticky notes for documentation - { - id: 'sticky1', - name: 'šŸ“ Webhook Trigger', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [250, 150], - parameters: { content: 'Receives login request' } - }, - { - id: 'sticky2', - name: 'šŸ“ Authenticate with Supabase', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [450, 150], - parameters: { content: 'Validates credentials' } - }, - { - id: 'sticky3', - name: 'šŸ“ Return Tokens', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [650, 150], - parameters: { content: 'Returns access and refresh tokens' } - }, - { - id: 'sticky4', - name: 'šŸ“ Return Error', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [650, 450], - parameters: { content: 'Returns error message' } - } - ], - connections: { - 'Webhook Trigger': { - main: [[{ node: 'Authenticate', type: 'main', index: 0 }]] - }, - 'Authenticate': { - main: [ - [{ node: 'Return Success', type: 'main', index: 0 }], - [{ node: 'Return Error', type: 'main', index: 0 }] - ] - } - } - }; - - const errors = validateWorkflowStructure(workflow); - - // Should have no errors - all sticky notes should be ignored - expect(errors).toEqual([]); - }); - }); - - describe('validation should still detect truly disconnected functional nodes', () => { - test('should detect disconnected HTTP node but ignore sticky note', () => { - const workflow: Partial = { - name: 'Test Workflow', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/test' } - }, - { - id: '2', - name: 'Disconnected HTTP', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [450, 300], - parameters: {} - }, - { - id: 'sticky1', - name: 'Sticky Note', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [250, 100], - parameters: { content: 'Note' } - } - ], - connections: {} // No connections - }; - - const errors = validateWorkflowStructure(workflow); - - // Should error on HTTP node, but NOT on sticky note - expect(errors.length).toBeGreaterThan(0); - const disconnectedError = errors.find(e => e.includes('Disconnected')); - expect(disconnectedError).toBeDefined(); - expect(disconnectedError).toContain('Disconnected HTTP'); - expect(disconnectedError).not.toContain('Sticky Note'); - }); - - test('should detect multiple disconnected functional nodes but ignore sticky notes', () => { - const workflow: Partial = { - name: 'Test Workflow', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/test' } - }, - { - id: '2', - name: 'Disconnected HTTP', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [450, 300], - parameters: {} - }, - { - id: '3', - name: 'Disconnected Set', - type: 'n8n-nodes-base.set', - typeVersion: 3, - position: [650, 300], - parameters: {} - }, - // Multiple sticky notes that should be ignored - { - id: 'sticky1', - name: 'Note 1', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [250, 100], - parameters: { content: 'Note 1' } - }, - { - id: 'sticky2', - name: 'Note 2', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [450, 100], - parameters: { content: 'Note 2' } - } - ], - connections: {} // No connections - }; - - const errors = validateWorkflowStructure(workflow); - - // Should error because there are no connections - // When there are NO connections, validation shows "Multi-node workflow has no connections" - // This is the expected behavior - it suggests connecting any two executable nodes - expect(errors.length).toBeGreaterThan(0); - const connectionError = errors.find(e => e.includes('no connections') || e.includes('Disconnected')); - expect(connectionError).toBeDefined(); - // Error should NOT mention sticky notes - expect(connectionError).not.toContain('Note 1'); - expect(connectionError).not.toContain('Note 2'); - }); - - test('should allow sticky notes but still validate functional node connections', () => { - const workflow: Partial = { - name: 'Test Workflow', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/test' } - }, - { - id: '2', - name: 'Connected HTTP', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [450, 300], - parameters: {} - }, - { - id: '3', - name: 'Disconnected Set', - type: 'n8n-nodes-base.set', - typeVersion: 3, - position: [650, 300], - parameters: {} - }, - { - id: 'sticky1', - name: 'Sticky Note', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [250, 100], - parameters: { content: 'Note' } - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'Connected HTTP', type: 'main', index: 0 }]] - } - } - }; - - const errors = validateWorkflowStructure(workflow); - - // Should error only on disconnected Set node - expect(errors.length).toBeGreaterThan(0); - const disconnectedError = errors.find(e => e.includes('Disconnected')); - expect(disconnectedError).toBeDefined(); - expect(disconnectedError).toContain('Disconnected Set'); - expect(disconnectedError).not.toContain('Connected HTTP'); - expect(disconnectedError).not.toContain('Sticky Note'); - }); - }); - - describe('regression tests - ensure sticky notes work like in n8n UI', () => { - test('single webhook with sticky notes should be valid (matches n8n UI behavior)', () => { - const workflow: Partial = { - name: 'Webhook Only with Notes', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/test' } - }, - { - id: 'sticky1', - name: 'Usage Instructions', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [250, 100], - parameters: { content: 'Call this webhook to trigger the workflow' } - } - ], - connections: {} - }; - - const errors = validateWorkflowStructure(workflow); - - // Webhook-only workflows are valid in n8n - // Sticky notes should not affect this - expect(errors).toEqual([]); - }); - - test('workflow with only sticky notes should be invalid (no executable nodes)', () => { - const workflow: Partial = { - name: 'Only Notes', - nodes: [ - { - id: 'sticky1', - name: 'Note 1', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [250, 100], - parameters: { content: 'Note 1' } - }, - { - id: 'sticky2', - name: 'Note 2', - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [450, 100], - parameters: { content: 'Note 2' } - } - ], - connections: {} - }; - - const errors = validateWorkflowStructure(workflow); - - // Should fail because there are no executable nodes - expect(errors.length).toBeGreaterThan(0); - expect(errors.some(e => e.includes('at least one executable node'))).toBe(true); - }); - - test('complex production workflow structure should validate correctly', () => { - // Tests a realistic production workflow structure - const workflow: Partial = { - name: 'Production API Endpoint', - nodes: [ - // Functional nodes - { - id: 'webhook1', - name: 'API Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300], - parameters: { path: '/api/endpoint' } - }, - { - id: 'validate1', - name: 'Validate Input', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [450, 300], - parameters: {} - }, - { - id: 'branch1', - name: 'Check Valid', - type: 'n8n-nodes-base.if', - typeVersion: 2, - position: [650, 300], - parameters: {} - }, - { - id: 'process1', - name: 'Process Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [850, 250], - parameters: {} - }, - { - id: 'success1', - name: 'Return Success', - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1, - position: [1050, 250], - parameters: {} - }, - { - id: 'error1', - name: 'Return Error', - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1, - position: [850, 350], - parameters: {} - }, - // Documentation sticky notes (11 notes like in real workflow) - ...Array.from({ length: 11 }, (_, i) => ({ - id: `sticky${i}`, - name: `šŸ“ Documentation ${i}`, - type: 'n8n-nodes-base.stickyNote', - typeVersion: 1, - position: [250 + i * 100, 100] as [number, number], - parameters: { content: `Documentation section ${i}` } - })) - ], - connections: { - 'API Webhook': { - main: [[{ node: 'Validate Input', type: 'main', index: 0 }]] - }, - 'Validate Input': { - main: [[{ node: 'Check Valid', type: 'main', index: 0 }]] - }, - 'Check Valid': { - main: [ - [{ node: 'Process Request', type: 'main', index: 0 }], - [{ node: 'Return Error', type: 'main', index: 0 }] - ] - }, - 'Process Request': { - main: [[{ node: 'Return Success', type: 'main', index: 0 }]] - } - } - }; - - const errors = validateWorkflowStructure(workflow); - - // Should be valid - all functional nodes connected, sticky notes ignored - expect(errors).toEqual([]); - }); - }); -}); diff --git a/tests/unit/services/n8n-validation.test.ts b/tests/unit/services/n8n-validation.test.ts index ff16a7d..76de23f 100644 --- a/tests/unit/services/n8n-validation.test.ts +++ b/tests/unit/services/n8n-validation.test.ts @@ -1830,4 +1830,513 @@ describe('n8n-validation', () => { expect(validateWorkflowStructure(forUpdate)).toEqual([]); }); }); + + describe('Sticky Notes Bug Fix', () => { + describe('sticky notes should be excluded from disconnected nodes validation', () => { + it('should allow workflow with sticky notes and connected functional nodes', () => { + const workflow: Partial = { + name: 'Test Workflow', + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/test' } + }, + { + id: '2', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 3, + position: [450, 300], + parameters: {} + }, + { + id: 'sticky1', + name: 'Documentation Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [250, 100], + parameters: { content: 'This is a documentation note' } + } + ], + connections: { + 'Webhook': { + main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] + } + } + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors).toEqual([]); + }); + + it('should handle multiple sticky notes without errors', () => { + const workflow: Partial = { + name: 'Documented Workflow', + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/test' } + }, + { + id: '2', + name: 'Process', + type: 'n8n-nodes-base.set', + typeVersion: 3, + position: [450, 300], + parameters: {} + }, + ...Array.from({ length: 10 }, (_, i) => ({ + id: `sticky${i}`, + name: `Note ${i}`, + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [100 + i * 50, 100] as [number, number], + parameters: { content: `Documentation note ${i}` } + })) + ], + connections: { + 'Webhook': { + main: [[{ node: 'Process', type: 'main', index: 0 }]] + } + } + }; + + const errors = validateWorkflowStructure(workflow); + expect(errors).toEqual([]); + }); + + it('should handle all sticky note type variations', () => { + const stickyTypes = [ + 'n8n-nodes-base.stickyNote', + 'nodes-base.stickyNote', + '@n8n/n8n-nodes-base.stickyNote' + ]; + + stickyTypes.forEach((stickyType, index) => { + const workflow: Partial = { + name: 'Test Workflow', + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/test' } + }, + { + id: `sticky${index}`, + name: `Note ${index}`, + type: stickyType, + typeVersion: 1, + position: [250, 100], + parameters: { content: `Note ${index}` } + } + ], + connections: {} + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors.every(e => !e.includes(`Note ${index}`))).toBe(true); + }); + }); + + it('should handle complex workflow with multiple sticky notes (real-world scenario)', () => { + const workflow: Partial = { + name: 'POST /auth/login', + nodes: [ + { + id: 'webhook1', + name: 'Webhook Trigger', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/auth/login', httpMethod: 'POST' } + }, + { + id: 'http1', + name: 'Authenticate', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 3, + position: [450, 300], + parameters: {} + }, + { + id: 'respond1', + name: 'Return Success', + type: 'n8n-nodes-base.respondToWebhook', + typeVersion: 1, + position: [650, 250], + parameters: {} + }, + { + id: 'respond2', + name: 'Return Error', + type: 'n8n-nodes-base.respondToWebhook', + typeVersion: 1, + position: [650, 350], + parameters: {} + }, + { + id: 'sticky1', + name: 'Webhook Trigger Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [250, 150], + parameters: { content: 'Receives login request' } + }, + { + id: 'sticky2', + name: 'Authenticate with Supabase Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [450, 150], + parameters: { content: 'Validates credentials' } + }, + { + id: 'sticky3', + name: 'Return Tokens Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [650, 150], + parameters: { content: 'Returns access and refresh tokens' } + }, + { + id: 'sticky4', + name: 'Return Error Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [650, 450], + parameters: { content: 'Returns error message' } + } + ], + connections: { + 'Webhook Trigger': { + main: [[{ node: 'Authenticate', type: 'main', index: 0 }]] + }, + 'Authenticate': { + main: [ + [{ node: 'Return Success', type: 'main', index: 0 }], + [{ node: 'Return Error', type: 'main', index: 0 }] + ] + } + } + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors).toEqual([]); + }); + }); + + describe('validation should still detect truly disconnected functional nodes', () => { + it('should detect disconnected HTTP node but ignore sticky note', () => { + const workflow: Partial = { + name: 'Test Workflow', + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/test' } + }, + { + id: '2', + name: 'Disconnected HTTP', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 3, + position: [450, 300], + parameters: {} + }, + { + id: 'sticky1', + name: 'Sticky Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [250, 100], + parameters: { content: 'Note' } + } + ], + connections: {} + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors.length).toBeGreaterThan(0); + const disconnectedError = errors.find(e => e.includes('Disconnected')); + expect(disconnectedError).toBeDefined(); + expect(disconnectedError).toContain('Disconnected HTTP'); + expect(disconnectedError).not.toContain('Sticky Note'); + }); + + it('should detect multiple disconnected functional nodes but ignore sticky notes', () => { + const workflow: Partial = { + name: 'Test Workflow', + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/test' } + }, + { + id: '2', + name: 'Disconnected HTTP', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 3, + position: [450, 300], + parameters: {} + }, + { + id: '3', + name: 'Disconnected Set', + type: 'n8n-nodes-base.set', + typeVersion: 3, + position: [650, 300], + parameters: {} + }, + { + id: 'sticky1', + name: 'Note 1', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [250, 100], + parameters: { content: 'Note 1' } + }, + { + id: 'sticky2', + name: 'Note 2', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [450, 100], + parameters: { content: 'Note 2' } + } + ], + connections: {} + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors.length).toBeGreaterThan(0); + const connectionError = errors.find(e => e.includes('no connections') || e.includes('Disconnected')); + expect(connectionError).toBeDefined(); + expect(connectionError).not.toContain('Note 1'); + expect(connectionError).not.toContain('Note 2'); + }); + + it('should allow sticky notes but still validate functional node connections', () => { + const workflow: Partial = { + name: 'Test Workflow', + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/test' } + }, + { + id: '2', + name: 'Connected HTTP', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 3, + position: [450, 300], + parameters: {} + }, + { + id: '3', + name: 'Disconnected Set', + type: 'n8n-nodes-base.set', + typeVersion: 3, + position: [650, 300], + parameters: {} + }, + { + id: 'sticky1', + name: 'Sticky Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [250, 100], + parameters: { content: 'Note' } + } + ], + connections: { + 'Webhook': { + main: [[{ node: 'Connected HTTP', type: 'main', index: 0 }]] + } + } + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors.length).toBeGreaterThan(0); + const disconnectedError = errors.find(e => e.includes('Disconnected')); + expect(disconnectedError).toBeDefined(); + expect(disconnectedError).toContain('Disconnected Set'); + expect(disconnectedError).not.toContain('Connected HTTP'); + expect(disconnectedError).not.toContain('Sticky Note'); + }); + }); + + describe('regression tests - ensure sticky notes work like in n8n UI', () => { + it('single webhook with sticky notes should be valid (matches n8n UI behavior)', () => { + const workflow: Partial = { + name: 'Webhook Only with Notes', + nodes: [ + { + id: '1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/test' } + }, + { + id: 'sticky1', + name: 'Usage Instructions', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [250, 100], + parameters: { content: 'Call this webhook to trigger the workflow' } + } + ], + connections: {} + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors).toEqual([]); + }); + + it('workflow with only sticky notes should be invalid (no executable nodes)', () => { + const workflow: Partial = { + name: 'Only Notes', + nodes: [ + { + id: 'sticky1', + name: 'Note 1', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [250, 100], + parameters: { content: 'Note 1' } + }, + { + id: 'sticky2', + name: 'Note 2', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [450, 100], + parameters: { content: 'Note 2' } + } + ], + connections: {} + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors.length).toBeGreaterThan(0); + expect(errors.some(e => e.includes('at least one executable node'))).toBe(true); + }); + + it('complex production workflow structure should validate correctly', () => { + const workflow: Partial = { + name: 'Production API Endpoint', + nodes: [ + { + id: 'webhook1', + name: 'API Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [250, 300], + parameters: { path: '/api/endpoint' } + }, + { + id: 'validate1', + name: 'Validate Input', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [450, 300], + parameters: {} + }, + { + id: 'branch1', + name: 'Check Valid', + type: 'n8n-nodes-base.if', + typeVersion: 2, + position: [650, 300], + parameters: {} + }, + { + id: 'process1', + name: 'Process Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 3, + position: [850, 250], + parameters: {} + }, + { + id: 'success1', + name: 'Return Success', + type: 'n8n-nodes-base.respondToWebhook', + typeVersion: 1, + position: [1050, 250], + parameters: {} + }, + { + id: 'error1', + name: 'Return Error', + type: 'n8n-nodes-base.respondToWebhook', + typeVersion: 1, + position: [850, 350], + parameters: {} + }, + ...Array.from({ length: 11 }, (_, i) => ({ + id: `sticky${i}`, + name: `Documentation ${i}`, + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [250 + i * 100, 100] as [number, number], + parameters: { content: `Documentation section ${i}` } + })) + ], + connections: { + 'API Webhook': { + main: [[{ node: 'Validate Input', type: 'main', index: 0 }]] + }, + 'Validate Input': { + main: [[{ node: 'Check Valid', type: 'main', index: 0 }]] + }, + 'Check Valid': { + main: [ + [{ node: 'Process Request', type: 'main', index: 0 }], + [{ node: 'Return Error', type: 'main', index: 0 }] + ] + }, + 'Process Request': { + main: [[{ node: 'Return Success', type: 'main', index: 0 }]] + } + } + }; + + const errors = validateWorkflowStructure(workflow); + + expect(errors).toEqual([]); + }); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-ai-subnode.test.ts b/tests/unit/services/workflow-validator-ai-subnode.test.ts deleted file mode 100644 index 75547ac..0000000 --- a/tests/unit/services/workflow-validator-ai-subnode.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -// Mock dependencies -vi.mock('@/database/node-repository'); -vi.mock('@/services/enhanced-config-validator'); -vi.mock('@/services/expression-validator'); -vi.mock('@/utils/logger'); - -describe('WorkflowValidator - AI Sub-Node Main Connection Detection', () => { - let validator: WorkflowValidator; - let mockNodeRepository: NodeRepository; - - beforeEach(() => { - vi.clearAllMocks(); - - mockNodeRepository = new NodeRepository({} as any) as any; - - if (!mockNodeRepository.getAllNodes) { - mockNodeRepository.getAllNodes = vi.fn(); - } - if (!mockNodeRepository.getNode) { - mockNodeRepository.getNode = vi.fn(); - } - - const nodeTypes: Record = { - 'nodes-base.manualTrigger': { - type: 'nodes-base.manualTrigger', - displayName: 'Manual Trigger', - package: 'n8n-nodes-base', - isTrigger: true, - outputs: ['main'], - properties: [], - }, - 'nodes-base.set': { - type: 'nodes-base.set', - displayName: 'Set', - package: 'n8n-nodes-base', - outputs: ['main'], - properties: [], - }, - 'nodes-langchain.lmChatGoogleGemini': { - type: 'nodes-langchain.lmChatGoogleGemini', - displayName: 'Google Gemini Chat Model', - package: '@n8n/n8n-nodes-langchain', - outputs: ['ai_languageModel'], - properties: [], - }, - 'nodes-langchain.memoryBufferWindow': { - type: 'nodes-langchain.memoryBufferWindow', - displayName: 'Window Buffer Memory', - package: '@n8n/n8n-nodes-langchain', - outputs: ['ai_memory'], - properties: [], - }, - 'nodes-langchain.embeddingsOpenAi': { - type: 'nodes-langchain.embeddingsOpenAi', - displayName: 'Embeddings OpenAI', - package: '@n8n/n8n-nodes-langchain', - outputs: ['ai_embedding'], - properties: [], - }, - 'nodes-langchain.agent': { - type: 'nodes-langchain.agent', - displayName: 'AI Agent', - package: '@n8n/n8n-nodes-langchain', - isAITool: true, - outputs: ['main'], - properties: [], - }, - 'nodes-langchain.openAi': { - type: 'nodes-langchain.openAi', - displayName: 'OpenAI', - package: '@n8n/n8n-nodes-langchain', - outputs: ['main'], - properties: [], - }, - 'nodes-langchain.textClassifier': { - type: 'nodes-langchain.textClassifier', - displayName: 'Text Classifier', - package: '@n8n/n8n-nodes-langchain', - outputs: ['={{}}'], // Dynamic expression-based outputs - properties: [], - }, - 'nodes-langchain.vectorStoreInMemory': { - type: 'nodes-langchain.vectorStoreInMemory', - displayName: 'In-Memory Vector Store', - package: '@n8n/n8n-nodes-langchain', - outputs: ['={{$parameter["mode"] === "retrieve" ? "main" : "ai_vectorStore"}}'], - properties: [], - }, - }; - - vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => { - return nodeTypes[nodeType] || null; - }); - vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes)); - - validator = new WorkflowValidator( - mockNodeRepository, - EnhancedConfigValidator as any - ); - }); - - function makeWorkflow(sourceType: string, sourceName: string, connectionKey: string = 'main') { - return { - nodes: [ - { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} }, - { id: '2', name: sourceName, type: sourceType, position: [200, 0], parameters: {} }, - { id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} }, - ], - connections: { - 'Manual Trigger': { - main: [[{ node: sourceName, type: 'main', index: 0 }]] - }, - [sourceName]: { - [connectionKey]: [[{ node: 'Set', type: connectionKey, index: 0 }]] - } - } - }; - } - - it('should flag LLM node (lmChatGoogleGemini) connected via main', async () => { - const workflow = makeWorkflow( - 'n8n-nodes-langchain.lmChatGoogleGemini', - 'Google Gemini' - ); - - const result = await validator.validateWorkflow(workflow as any); - - const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION'); - expect(error).toBeDefined(); - expect(error!.message).toContain('ai_languageModel'); - expect(error!.message).toContain('AI sub-node'); - expect(error!.nodeName).toBe('Google Gemini'); - }); - - it('should flag memory node (memoryBufferWindow) connected via main', async () => { - const workflow = makeWorkflow( - 'n8n-nodes-langchain.memoryBufferWindow', - 'Window Buffer Memory' - ); - - const result = await validator.validateWorkflow(workflow as any); - - const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION'); - expect(error).toBeDefined(); - expect(error!.message).toContain('ai_memory'); - }); - - it('should flag embeddings node connected via main', async () => { - const workflow = makeWorkflow( - 'n8n-nodes-langchain.embeddingsOpenAi', - 'Embeddings OpenAI' - ); - - const result = await validator.validateWorkflow(workflow as any); - - const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION'); - expect(error).toBeDefined(); - expect(error!.message).toContain('ai_embedding'); - }); - - it('should NOT flag regular langchain nodes (agent, openAi) connected via main', async () => { - const workflow1 = makeWorkflow('n8n-nodes-langchain.agent', 'AI Agent'); - const workflow2 = makeWorkflow('n8n-nodes-langchain.openAi', 'OpenAI'); - - const result1 = await validator.validateWorkflow(workflow1 as any); - const result2 = await validator.validateWorkflow(workflow2 as any); - - expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); - expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); - }); - - it('should NOT flag dynamic-output nodes (expression-based outputs)', async () => { - const workflow1 = makeWorkflow('n8n-nodes-langchain.textClassifier', 'Text Classifier'); - const workflow2 = makeWorkflow('n8n-nodes-langchain.vectorStoreInMemory', 'Vector Store'); - - const result1 = await validator.validateWorkflow(workflow1 as any); - const result2 = await validator.validateWorkflow(workflow2 as any); - - expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); - expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); - }); - - it('should NOT flag AI sub-node connected via correct AI type', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} }, - { id: '2', name: 'AI Agent', type: 'n8n-nodes-langchain.agent', position: [200, 0], parameters: {} }, - { id: '3', name: 'Google Gemini', type: 'n8n-nodes-langchain.lmChatGoogleGemini', position: [200, 200], parameters: {} }, - ], - connections: { - 'Manual Trigger': { - main: [[{ node: 'AI Agent', type: 'main', index: 0 }]] - }, - 'Google Gemini': { - ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); - }); - - it('should NOT flag unknown/community nodes not in database', async () => { - const workflow = makeWorkflow('n8n-nodes-community.someNode', 'Community Node'); - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); - }); -}); diff --git a/tests/unit/services/workflow-validator-comprehensive.test.ts b/tests/unit/services/workflow-validator-comprehensive.test.ts deleted file mode 100644 index 3010e1f..0000000 --- a/tests/unit/services/workflow-validator-comprehensive.test.ts +++ /dev/null @@ -1,2079 +0,0 @@ -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; -import { ExpressionValidator } from '@/services/expression-validator'; -import { createWorkflow } from '@tests/utils/builders/workflow.builder'; -import type { WorkflowNode, Workflow } from '@/types/n8n-api'; - -// Mock dependencies -vi.mock('@/database/node-repository'); -vi.mock('@/services/enhanced-config-validator'); -vi.mock('@/services/expression-validator'); -vi.mock('@/utils/logger'); - -describe('WorkflowValidator - Comprehensive Tests', () => { - let validator: WorkflowValidator; - let mockNodeRepository: NodeRepository; - let mockEnhancedConfigValidator: typeof EnhancedConfigValidator; - - beforeEach(() => { - vi.clearAllMocks(); - - // Create mock instances - mockNodeRepository = new NodeRepository({} as any) as any; - mockEnhancedConfigValidator = EnhancedConfigValidator as any; - - // Ensure the mock repository has all necessary methods - if (!mockNodeRepository.getAllNodes) { - mockNodeRepository.getAllNodes = vi.fn(); - } - if (!mockNodeRepository.getNode) { - mockNodeRepository.getNode = vi.fn(); - } - - // Mock common node types data - const nodeTypes: Record = { - 'nodes-base.webhook': { - type: 'nodes-base.webhook', - displayName: 'Webhook', - package: 'n8n-nodes-base', - version: 2, - isVersioned: true, - properties: [], - category: 'trigger' - }, - 'nodes-base.httpRequest': { - type: 'nodes-base.httpRequest', - displayName: 'HTTP Request', - package: 'n8n-nodes-base', - version: 4, - isVersioned: true, - properties: [], - category: 'network' - }, - 'nodes-base.set': { - type: 'nodes-base.set', - displayName: 'Set', - package: 'n8n-nodes-base', - version: 3, - isVersioned: true, - properties: [], - category: 'data' - }, - 'nodes-base.code': { - type: 'nodes-base.code', - displayName: 'Code', - package: 'n8n-nodes-base', - version: 2, - isVersioned: true, - properties: [], - category: 'code' - }, - 'nodes-base.manualTrigger': { - type: 'nodes-base.manualTrigger', - displayName: 'Manual Trigger', - package: 'n8n-nodes-base', - version: 1, - isVersioned: true, - properties: [], - category: 'trigger' - }, - 'nodes-base.if': { - type: 'nodes-base.if', - displayName: 'IF', - package: 'n8n-nodes-base', - version: 2, - isVersioned: true, - properties: [], - category: 'logic' - }, - 'nodes-base.slack': { - type: 'nodes-base.slack', - displayName: 'Slack', - package: 'n8n-nodes-base', - version: 2, - isVersioned: true, - properties: [], - category: 'communication' - }, - 'nodes-base.googleSheets': { - type: 'nodes-base.googleSheets', - displayName: 'Google Sheets', - package: 'n8n-nodes-base', - version: 4, - isVersioned: true, - properties: [], - category: 'data' - }, - 'nodes-langchain.agent': { - type: 'nodes-langchain.agent', - displayName: 'AI Agent', - package: '@n8n/n8n-nodes-langchain', - version: 1, - isVersioned: true, - properties: [], - isAITool: true, - category: 'ai' - }, - 'nodes-base.postgres': { - type: 'nodes-base.postgres', - displayName: 'Postgres', - package: 'n8n-nodes-base', - version: 2, - isVersioned: true, - properties: [], - category: 'database' - }, - 'community.customNode': { - type: 'community.customNode', - displayName: 'Custom Node', - package: 'n8n-nodes-custom', - version: 1, - isVersioned: false, - properties: [], - isAITool: false, - category: 'custom' - } - }; - - // Set up default mock behaviors - vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => { - // Handle normalization for custom nodes - if (nodeType === 'n8n-nodes-custom.customNode') { - return { - type: 'n8n-nodes-custom.customNode', - displayName: 'Custom Node', - package: 'n8n-nodes-custom', - version: 1, - isVersioned: false, - properties: [], - isAITool: false - }; - } - - return nodeTypes[nodeType] || null; - }); - - // Mock getAllNodes for NodeSimilarityService - vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes)); - - vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ - errors: [], - warnings: [], - suggestions: [], - mode: 'operation' as const, - valid: true, - visibleProperties: [], - hiddenProperties: [] - } as any); - - vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ - valid: true, - errors: [], - warnings: [], - usedVariables: new Set(), - usedNodes: new Set() - }); - - // Create validator instance - validator = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator); - }); - - describe('validateWorkflow', () => { - it('should validate a minimal valid workflow', async () => { - const workflow = createWorkflow('Test Workflow') - .addWebhookNode({ name: 'Webhook' }) - .build(); - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - expect(result.statistics.totalNodes).toBe(1); - expect(result.statistics.enabledNodes).toBe(1); - expect(result.statistics.triggerNodes).toBe(1); - }); - - it('should validate a workflow with all options disabled', async () => { - const workflow = createWorkflow('Test Workflow') - .addWebhookNode({ name: 'Webhook' }) - .build(); - - const result = await validator.validateWorkflow(workflow as any, { - validateNodes: false, - validateConnections: false, - validateExpressions: false - }); - - expect(result.valid).toBe(true); - expect(mockNodeRepository.getNode).not.toHaveBeenCalled(); - expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled(); - }); - - it('should handle validation errors gracefully', async () => { - const workflow = createWorkflow('Test Workflow') - .addWebhookNode({ name: 'Webhook' }) - .build(); - - // Make the validation throw an error - vi.mocked(mockNodeRepository.getNode).mockImplementation(() => { - throw new Error('Database error'); - }); - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors.some(e => e.message.includes('Database error'))).toBe(true); - }); - - it('should use different validation profiles', async () => { - const workflow = createWorkflow('Test Workflow') - .addWebhookNode({ name: 'Webhook' }) - .build(); - - const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict'] as const; - - for (const profile of profiles) { - const result = await validator.validateWorkflow(workflow as any, { profile }); - expect(result).toBeDefined(); - expect(mockEnhancedConfigValidator.validateWithMode).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.any(Array), - 'operation', - profile - ); - } - }); - }); - - describe('validateWorkflowStructure', () => { - it('should error when nodes array is missing', async () => { - const workflow = { connections: {} } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true); - }); - - it('should error when connections object is missing', async () => { - const workflow = { nodes: [] } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true); - }); - - it('should warn when workflow has no nodes', async () => { - const workflow = { nodes: [], connections: {} } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(true); // Empty workflows are valid but get a warning - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0].message).toBe('Workflow is empty - no nodes defined'); - }); - - it('should error for single non-webhook node workflow', async () => { - const workflow = { - nodes: [{ - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {} - }], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true); - }); - - it('should warn for webhook without connections', async () => { - const workflow = { - nodes: [{ - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {}, - typeVersion: 2 - }], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(true); - expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true); - }); - - it('should error for multi-node workflow without connections', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true); - }); - - it('should detect duplicate node names', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [300, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true); - }); - - it('should detect duplicate node IDs', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook1', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '1', - name: 'Webhook2', - type: 'n8n-nodes-base.webhook', - position: [300, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true); - }); - - it('should count trigger nodes correctly', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Schedule', - type: 'n8n-nodes-base.scheduleTrigger', - position: [100, 300], - parameters: {} - }, - { - id: '3', - name: 'Manual', - type: 'n8n-nodes-base.manualTrigger', - position: [100, 500], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.statistics.triggerNodes).toBe(3); - }); - - it('should warn when no trigger nodes exist', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Code', - type: 'n8n-nodes-base.code', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Set': { - main: [[{ node: 'Code', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true); - }); - - it('should not count disabled nodes in enabledNodes count', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {}, - disabled: true - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.statistics.totalNodes).toBe(2); - expect(result.statistics.enabledNodes).toBe(1); - }); - }); - - describe('validateAllNodes', () => { - it('should skip disabled nodes', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {}, - disabled: true - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(mockNodeRepository.getNode).not.toHaveBeenCalled(); - }); - - it('should accept both nodes-base and n8n-nodes-base prefixes as valid', async () => { - // This test verifies the fix for false positives - both prefixes are valid - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'nodes-base.webhook', // This is now valid (normalized internally) - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - // Mock the normalized node lookup - (mockNodeRepository.getNode as any) = vi.fn((type: string) => { - if (type === 'nodes-base.webhook') { - return { - nodeType: 'nodes-base.webhook', - displayName: 'Webhook', - properties: [], - isVersioned: false - }; - } - return null; - }); - - const result = await validator.validateWorkflow(workflow as any); - - // Should NOT error for nodes-base prefix - it's valid! - expect(result.valid).toBe(true); - expect(result.errors.some(e => e.message.includes('Invalid node type'))).toBe(false); - }); - - it.skip('should handle unknown node types with suggestions', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'httpRequest', // Missing package prefix - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('Unknown node type: "httpRequest"'))).toBe(true); - expect(result.errors.some(e => e.message.includes('Did you mean "n8n-nodes-base.httpRequest"?'))).toBe(true); - }); - - it('should try normalized types for n8n-nodes-base', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook'); - }); - - it('should validate typeVersion but skip parameter validation for langchain nodes', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1, - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - // After v2.17.4 fix: Langchain nodes SHOULD call getNode for typeVersion validation - // This prevents invalid typeVersion values from bypassing validation - // But they skip parameter validation (handled by dedicated AI validators) - expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent'); - - // Should not have typeVersion validation errors (other AI-specific errors may exist) - const typeVersionErrors = result.errors.filter(e => e.message.includes('typeVersion')); - expect(typeVersionErrors).toEqual([]); - }); - - it('should catch invalid typeVersion for langchain nodes (v2.17.4 bug fix)', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 99999, // Invalid - exceeds maximum - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - // Critical: Before v2.17.4, this would pass validation but fail at runtime - // After v2.17.4: Invalid typeVersion is caught during validation - expect(result.valid).toBe(false); - expect(result.errors.some(e => - e.message.includes('typeVersion 99999 exceeds maximum') - )).toBe(true); - }); - - it('should validate typeVersion for versioned nodes', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - // Missing typeVersion - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true); - }); - - it('should error for invalid typeVersion', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {}, - typeVersion: 'invalid' as any - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true); - }); - - it('should warn for outdated typeVersion', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {}, - typeVersion: 1 // Current version is 2 - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true); - }); - - it('should error for typeVersion exceeding maximum', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {}, - typeVersion: 10 // Max is 2 - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true); - }); - - it('should add node validation errors and warnings', async () => { - vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ - errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }], - warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }], - suggestions: [], - mode: 'operation' as const, - valid: false, - visibleProperties: [], - hiddenProperties: [] - } as any); - - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - typeVersion: 4 - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true); - expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true); - }); - - it('should handle node validation failures gracefully', async () => { - vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockImplementation(() => { - throw new Error('Validation error'); - }); - - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - typeVersion: 4 - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true); - }); - }); - - describe('validateConnections', () => { - it('should validate valid connections', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'Set', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.statistics.validConnections).toBe(1); - expect(result.statistics.invalidConnections).toBe(0); - }); - - it('should error for connection from non-existent node', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - } - ], - connections: { - 'NonExistent': { - main: [[{ node: 'Webhook', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true); - expect(result.statistics.invalidConnections).toBe(1); - }); - - it('should error when using node ID instead of name in source', async () => { - const workflow = { - nodes: [ - { - id: 'webhook-id', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: 'set-id', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'webhook-id': { // Using ID instead of name - main: [[{ node: 'Set', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Connection uses node ID \'webhook-id\' instead of node name \'Webhook\''))).toBe(true); - }); - - it('should error for connection to non-existent node', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true); - expect(result.statistics.invalidConnections).toBe(1); - }); - - it('should error when using node ID instead of name in target', async () => { - const workflow = { - nodes: [ - { - id: 'webhook-id', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: 'set-id', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'set-id', type: 'main', index: 0 }]] // Using ID instead of name - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Connection target uses node ID \'set-id\' instead of node name \'Set\''))).toBe(true); - }); - - it('should warn for connection to disabled node', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {}, - disabled: true - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'Set', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true); - }); - - it('should validate error outputs', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Error Handler', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'HTTP': { - error: [[{ node: 'Error Handler', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.statistics.validConnections).toBe(1); - }); - - it('should validate AI tool connections', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Agent', - type: '@n8n/n8n-nodes-langchain.agent', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Tool', - type: 'n8n-nodes-base.httpRequest', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Agent': { - ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.statistics.validConnections).toBe(1); - }); - - it('should warn for community nodes used as AI tools', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Agent', - type: '@n8n/n8n-nodes-langchain.agent', - position: [100, 100], - parameters: {}, - typeVersion: 1 - }, - { - id: '2', - name: 'CustomTool', - type: 'n8n-nodes-custom.customNode', - position: [300, 100], - parameters: {}, - typeVersion: 1 - } - ], - connections: { - 'Agent': { - ai_tool: [[{ node: 'CustomTool', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Community node "CustomTool" is being used as an AI tool'))).toBe(true); - }); - - it('should warn for orphaned nodes', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Orphaned', - type: 'n8n-nodes-base.code', - position: [500, 100], - parameters: {} - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'Set', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('not reachable from any trigger node') && w.nodeName === 'Orphaned')).toBe(true); - }); - - it('should detect cycles in workflow', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Node1', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Node2', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Node3', - type: 'n8n-nodes-base.set', - position: [500, 100], - parameters: {} - } - ], - connections: { - 'Node1': { - main: [[{ node: 'Node2', type: 'main', index: 0 }]] - }, - 'Node2': { - main: [[{ node: 'Node3', type: 'main', index: 0 }]] - }, - 'Node3': { - main: [[{ node: 'Node1', type: 'main', index: 0 }]] // Creates cycle - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true); - }); - - it('should handle null connections properly', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'IF', - type: 'n8n-nodes-base.if', - position: [100, 100], - parameters: {}, - typeVersion: 2 - }, - { - id: '2', - name: 'True Branch', - type: 'n8n-nodes-base.set', - position: [300, 50], - parameters: {}, - typeVersion: 3 - } - ], - connections: { - 'IF': { - main: [ - [{ node: 'True Branch', type: 'main', index: 0 }], - null // False branch not connected - ] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.statistics.validConnections).toBe(1); - expect(result.valid).toBe(true); - }); - }); - - describe('validateExpressions', () => { - it('should validate expressions in node parameters', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: { - values: { - string: [ - { - name: 'field', - value: '={{ $json.data }}' - } - ] - } - } - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'Set', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith( - expect.objectContaining({ values: expect.any(Object) }), - expect.objectContaining({ - availableNodes: expect.arrayContaining(['Webhook']), - currentNodeName: 'Set', - hasInputData: true - }) - ); - }); - - it('should add expression errors to result', async () => { - vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ - valid: false, - errors: ['Invalid expression syntax'], - warnings: ['Deprecated variable usage'], - usedVariables: new Set(['$json']), - usedNodes: new Set() - }); - - const workflow = { - nodes: [ - { - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: { - value: '={{ invalid }}' - } - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true); - expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true); - expect(result.statistics.expressionsValidated).toBe(1); - }); - - it('should skip expression validation for disabled nodes', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: { - value: '={{ $json.data }}' - }, - disabled: true - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled(); - }); - }); - - describe('checkWorkflowPatterns', () => { - it('should suggest error handling for large workflows', async () => { - const builder = createWorkflow('Large Workflow'); - - // Add more than 3 nodes - for (let i = 0; i < 5; i++) { - builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Set${i}` }); - } - - const workflow = builder.build() as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true); - }); - - it('should warn about long linear chains', async () => { - const builder = createWorkflow('Linear Workflow'); - - // Create a chain of 12 nodes - const nodeNames: string[] = []; - for (let i = 0; i < 12; i++) { - const nodeName = `Node${i}`; - builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: nodeName }); - nodeNames.push(nodeName); - } - - // Connect them sequentially - builder.connectSequentially(nodeNames); - - const workflow = builder.build() as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true); - }); - - it('should warn about missing credentials', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Slack', - type: 'n8n-nodes-base.slack', - position: [100, 100], - parameters: {}, - credentials: { - slackApi: {} // Missing id - } - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Missing credentials configuration for slackApi'))).toBe(true); - }); - - it('should warn about AI agents without tools', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Agent', - type: '@n8n/n8n-nodes-langchain.agent', - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true); - }); - - it('should NOT warn about AI agents WITH tools properly connected', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Calculator Tool', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Agent', - type: '@n8n/n8n-nodes-langchain.agent', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Calculator Tool': { - ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - // Should NOT have warning about missing tools - expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(false); - }); - - it('should suggest community package setting for AI tools', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Agent', - type: '@n8n/n8n-nodes-langchain.agent', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Tool', - type: 'n8n-nodes-base.httpRequest', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Agent': { - ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE'))).toBe(true); - }); - }); - - describe('checkNodeErrorHandling', () => { - it('should error when node-level properties are inside parameters', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - typeVersion: 4, - parameters: { - url: 'https://api.example.com', - onError: 'continueRegularOutput', // Wrong location! - retryOnFail: true, // Wrong location! - credentials: {} // Wrong location! - } - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true); - expect(result.errors.some(e => e.details?.fix?.includes('Move these properties from node.parameters to the node level'))).toBe(true); - }); - - it('should validate onError property values', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - onError: 'invalidValue' as any - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true); - }); - - it('should warn about deprecated continueOnFail', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - continueOnFail: true - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true); - }); - - it('should error for conflicting error handling properties', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - continueOnFail: true, - onError: 'continueRegularOutput' - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true); - }); - - it('should validate retry configuration', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - retryOnFail: true, - maxTries: 'invalid' as any, - waitBetweenTries: -1000 - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true); - expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true); - }); - - it('should warn about excessive retry values', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - retryOnFail: true, - maxTries: 15, - waitBetweenTries: 400000 - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('maxTries is set to 15'))).toBe(true); - expect(result.warnings.some(w => w.message.includes('waitBetweenTries is set to 400000ms'))).toBe(true); - }); - - it('should warn about retryOnFail without maxTries', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - retryOnFail: true - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('retryOnFail is enabled but maxTries is not specified'))).toBe(true); - }); - - it('should validate other node-level properties', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {}, - typeVersion: 3, - alwaysOutputData: 'invalid' as any, - executeOnce: 'invalid' as any, - disabled: 'invalid' as any, - notesInFlow: 'invalid' as any, - notes: 123 as any - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - - expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true); - expect(result.errors.some(e => e.message.includes('executeOnce must be a boolean'))).toBe(true); - expect(result.errors.some(e => e.message.includes('disabled must be a boolean'))).toBe(true); - expect(result.errors.some(e => e.message.includes('notesInFlow must be a boolean'))).toBe(true); - expect(result.errors.some(e => e.message.includes('notes must be a string'))).toBe(true); - }); - - it('should warn about executeOnce', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {}, - executeOnce: true - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('executeOnce is enabled'))).toBe(true); - }); - - it('should warn error-prone nodes without error handling', async () => { - const errorProneNodes = [ - { type: 'n8n-nodes-base.httpRequest', message: 'HTTP Request', version: 4 }, - { type: 'n8n-nodes-base.webhook', message: 'Webhook', version: 2 }, - { type: 'n8n-nodes-base.postgres', message: 'Database operation', version: 2 }, - { type: 'n8n-nodes-base.slack', message: 'slack node', version: 2 } - ]; - - for (const nodeInfo of errorProneNodes) { - const workflow = { - nodes: [ - { - id: '1', - name: 'Node', - type: nodeInfo.type, - position: [100, 100], - parameters: {}, - typeVersion: nodeInfo.version - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes(nodeInfo.message) && w.message.includes('without error handling'))).toBe(true); - } - }); - - it('should warn about conflicting error handling', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - continueOnFail: true, - retryOnFail: true - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => w.message.includes('Both continueOnFail and retryOnFail are enabled'))).toBe(true); - }); - - it('should suggest alwaysOutputData for debugging', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - retryOnFail: true - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('Consider enabling alwaysOutputData'))).toBe(true); - }); - - it('should provide general error handling suggestions', async () => { - const builder = createWorkflow('No Error Handling'); - - // Add 6 nodes without error handling - for (let i = 0; i < 6; i++) { - builder.addCustomNode('n8n-nodes-base.httpRequest', 4, {}, { name: `HTTP${i}` }); - } - - const workflow = builder.build() as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('Most nodes lack error handling'))).toBe(true); - }); - - it('should suggest replacing deprecated error handling', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - continueOnFail: true - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('Replace "continueOnFail: true" with "onError:'))).toBe(true); - }); - }); - - describe('generateSuggestions', () => { - it('should suggest adding trigger for workflows without triggers', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true); - }); - - it('should provide connection examples for connection errors', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: {} // Missing connections - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true); - expect(result.suggestions.some(s => s.includes('Use node NAMES (not IDs) in connections'))).toBe(true); - }); - - it('should suggest error handling when missing', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'HTTP', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('Add error handling'))).toBe(true); - }); - - it('should suggest breaking up large workflows', async () => { - const builder = createWorkflow('Large Workflow'); - - // Add 25 nodes - for (let i = 0; i < 25; i++) { - builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Node${i}` }); - } - - const workflow = builder.build() as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('Consider breaking this workflow into smaller sub-workflows'))).toBe(true); - }); - - it('should suggest Code node for complex expressions', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Complex', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: { - field1: '={{ $json.a }}', - field2: '={{ $json.b }}', - field3: '={{ $json.c }}', - field4: '={{ $json.d }}', - field5: '={{ $json.e }}', - field6: '={{ $json.f }}' - } - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('Consider using a Code node for complex data transformations'))).toBe(true); - }); - - it('should suggest minimal workflow structure', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.suggestions.some(s => s.includes('A minimal workflow needs'))).toBe(true); - }); - }); - - describe('findSimilarNodeTypes', () => { - it.skip('should find similar node types for common mistakes', async () => { - // Test that webhook without prefix gets suggestions - const webhookWorkflow = { - nodes: [ - { - id: '1', - name: 'Node', - type: 'webhook', - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const webhookResult = await validator.validateWorkflow(webhookWorkflow); - - // Check that we get an unknown node error with suggestions - const unknownNodeError = webhookResult.errors.find(e => - e.message && e.message.includes('Unknown node type') - ); - expect(unknownNodeError).toBeDefined(); - - // For webhook, it should definitely suggest nodes-base.webhook - expect(unknownNodeError?.message).toContain('nodes-base.webhook'); - - // Test that slack without prefix gets suggestions - const slackWorkflow = { - nodes: [ - { - id: '1', - name: 'Node', - type: 'slack', - position: [100, 100], - parameters: {} - } - ], - connections: {} - } as any; - - const slackResult = await validator.validateWorkflow(slackWorkflow); - const slackError = slackResult.errors.find(e => - e.message && e.message.includes('Unknown node type') - ); - expect(slackError).toBeDefined(); - expect(slackError?.message).toContain('nodes-base.slack'); - }); - }); - - describe('Integration Tests', () => { - it('should validate a complex workflow with multiple issues', async () => { - const workflow = { - nodes: [ - // Valid trigger - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {}, - typeVersion: 2 - }, - // Node with valid alternative prefix (no longer an error) - { - id: '2', - name: 'HTTP1', - type: 'nodes-base.httpRequest', // Valid prefix (normalized internally) - position: [300, 100], - parameters: {} - }, - // Node with missing typeVersion - { - id: '3', - name: 'Slack', - type: 'n8n-nodes-base.slack', - position: [500, 100], - parameters: {} - }, - // Disabled node - { - id: '4', - name: 'Disabled', - type: 'n8n-nodes-base.set', - position: [700, 100], - parameters: {}, - disabled: true - }, - // Node with error handling in wrong place - { - id: '5', - name: 'HTTP2', - type: 'n8n-nodes-base.httpRequest', - position: [900, 100], - parameters: { - onError: 'continueRegularOutput' - }, - typeVersion: 4 - }, - // Orphaned node - { - id: '6', - name: 'Orphaned', - type: 'n8n-nodes-base.code', - position: [1100, 100], - parameters: {}, - typeVersion: 2 - }, - // AI Agent without tools - { - id: '7', - name: 'Agent', - type: '@n8n/n8n-nodes-langchain.agent', - position: [100, 300], - parameters: {}, - typeVersion: 1 - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'HTTP1', type: 'main', index: 0 }]] - }, - 'HTTP1': { - main: [[{ node: 'Slack', type: 'main', index: 0 }]] - }, - 'Slack': { - main: [[{ node: 'Disabled', type: 'main', index: 0 }]] - }, - // Using ID instead of name - '5': { - main: [[{ node: 'Agent', type: 'main', index: 0 }]] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - // Should have multiple errors (but not for the nodes-base prefix) - expect(result.valid).toBe(false); - expect(result.errors.length).toBeGreaterThan(2); // Reduced by 1 since nodes-base prefix is now valid - - // Specific errors (removed the invalid node type error as it's no longer invalid) - expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true); - expect(result.errors.some(e => e.message.includes('Node-level properties onError are in the wrong location'))).toBe(true); - expect(result.errors.some(e => e.message.includes('Connection uses node ID \'5\' instead of node name'))).toBe(true); - - // Warnings - expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true); - expect(result.warnings.some(w => w.message.includes('not reachable from any trigger node') && w.nodeName === 'Orphaned')).toBe(true); - expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true); - - // Statistics - expect(result.statistics.totalNodes).toBe(7); - expect(result.statistics.enabledNodes).toBe(6); - expect(result.statistics.triggerNodes).toBe(1); - expect(result.statistics.invalidConnections).toBeGreaterThan(0); - - // Suggestions - expect(result.suggestions.length).toBeGreaterThan(0); - }); - - it('should validate a perfect workflow', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Manual Trigger', - type: 'n8n-nodes-base.manualTrigger', - position: [250, 300], - parameters: {}, - typeVersion: 1 - }, - { - id: '2', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [450, 300], - parameters: { - url: 'https://api.example.com/data', - method: 'GET' - }, - typeVersion: 4, - onError: 'continueErrorOutput', - retryOnFail: true, - maxTries: 3, - waitBetweenTries: 1000 - }, - { - id: '3', - name: 'Process Data', - type: 'n8n-nodes-base.code', - position: [650, 300], - parameters: { - jsCode: 'return items;' - }, - typeVersion: 2 - }, - { - id: '4', - name: 'Error Handler', - type: 'n8n-nodes-base.set', - position: [650, 500], - parameters: { - values: { - string: [ - { - name: 'error', - value: 'An error occurred' - } - ] - } - }, - typeVersion: 3 - } - ], - connections: { - 'Manual Trigger': { - main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] - }, - 'HTTP Request': { - main: [ - [{ node: 'Process Data', type: 'main', index: 0 }], - [{ node: 'Error Handler', type: 'main', index: 0 }] - ] - } - } - } as any; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - expect(result.warnings).toHaveLength(0); - expect(result.statistics.validConnections).toBe(3); - expect(result.statistics.invalidConnections).toBe(0); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-connections.test.ts b/tests/unit/services/workflow-validator-connections.test.ts index 411e55e..7d44b56 100644 --- a/tests/unit/services/workflow-validator-connections.test.ts +++ b/tests/unit/services/workflow-validator-connections.test.ts @@ -915,4 +915,269 @@ describe('WorkflowValidator - Connection Validation (#620)', () => { expect(warning!.message).toContain('"unmatched" branch has no effect'); }); }); + + // ─── Error Output Validation (absorbed from workflow-validator-error-outputs) ── + + describe('Error Output Configuration', () => { + it('should detect incorrect configuration - multiple nodes in same array', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'Validate Input', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [-400, 64], parameters: {} }, + { id: '2', name: 'Filter URLs', type: 'n8n-nodes-base.filter', typeVersion: 2.2, position: [-176, 64], parameters: {} }, + { id: '3', name: 'Error Response1', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.5, position: [-160, 240], parameters: {} }, + ], + connections: { + 'Validate Input': { + main: [[ + { node: 'Filter URLs', type: 'main', index: 0 }, + { node: 'Error Response1', type: 'main', index: 0 }, + ]], + }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.valid).toBe(false); + expect(result.errors.some(e => + e.message.includes('Incorrect error output configuration') && + e.message.includes('Error Response1') && + e.message.includes('appear to be error handlers but are in main[0]'), + )).toBe(true); + const errorMsg = result.errors.find(e => e.message.includes('Incorrect error output configuration')); + expect(errorMsg?.message).toContain('INCORRECT (current)'); + expect(errorMsg?.message).toContain('CORRECT (should be)'); + }); + + it('should validate correct configuration - separate arrays', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'Validate Input', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [-400, 64], parameters: {}, onError: 'continueErrorOutput' }, + { id: '2', name: 'Filter URLs', type: 'n8n-nodes-base.filter', typeVersion: 2.2, position: [-176, 64], parameters: {} }, + { id: '3', name: 'Error Response1', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.5, position: [-160, 240], parameters: {} }, + ], + connections: { + 'Validate Input': { + main: [ + [{ node: 'Filter URLs', type: 'main', index: 0 }], + [{ node: 'Error Response1', type: 'main', index: 0 }], + ], + }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false); + }); + + it('should detect onError without error connections', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [100, 100], parameters: {}, onError: 'continueErrorOutput' }, + { id: '2', name: 'Process Data', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + ], + connections: { + 'HTTP Request': { main: [[{ node: 'Process Data', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => + e.nodeName === 'HTTP Request' && + e.message.includes("has onError: 'continueErrorOutput' but no error output connections"), + )).toBe(true); + }); + + it('should warn about error connections without onError', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [100, 100], parameters: {} }, + { id: '2', name: 'Process Data', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 300], parameters: {} }, + ], + connections: { + 'HTTP Request': { + main: [ + [{ node: 'Process Data', type: 'main', index: 0 }], + [{ node: 'Error Handler', type: 'main', index: 0 }], + ], + }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.warnings.some(w => + w.nodeName === 'HTTP Request' && + w.message.includes('error output connections in main[1] but missing onError'), + )).toBe(true); + }); + }); + + describe('Error Handler Detection', () => { + it('should detect error handler nodes by name', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'API Call', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, + { id: '2', name: 'Process Success', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + { id: '3', name: 'Handle Error', type: 'n8n-nodes-base.set', position: [300, 300], parameters: {} }, + ], + connections: { + 'API Call': { main: [[{ node: 'Process Success', type: 'main', index: 0 }, { node: 'Handle Error', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => e.message.includes('Handle Error') && e.message.includes('appear to be error handlers'))).toBe(true); + }); + + it('should detect error handler nodes by type', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, + { id: '2', name: 'Process', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + { id: '3', name: 'Respond', type: 'n8n-nodes-base.respondToWebhook', position: [300, 300], parameters: {} }, + ], + connections: { + 'Webhook': { main: [[{ node: 'Process', type: 'main', index: 0 }, { node: 'Respond', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => e.message.includes('Respond') && e.message.includes('appear to be error handlers'))).toBe(true); + }); + + it('should not flag non-error nodes in main[0]', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'Start', type: 'n8n-nodes-base.manualTrigger', position: [100, 100], parameters: {} }, + { id: '2', name: 'First Process', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + { id: '3', name: 'Second Process', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} }, + ], + connections: { + 'Start': { main: [[{ node: 'First Process', type: 'main', index: 0 }, { node: 'Second Process', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false); + }); + }); + + describe('Complex Error Patterns', () => { + it('should handle multiple error handlers correctly in main[1]', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'continueErrorOutput' }, + { id: '2', name: 'Process', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + { id: '3', name: 'Log Error', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} }, + { id: '4', name: 'Send Error Email', type: 'n8n-nodes-base.emailSend', position: [300, 300], parameters: {} }, + ], + connections: { + 'HTTP Request': { + main: [ + [{ node: 'Process', type: 'main', index: 0 }], + [{ node: 'Log Error', type: 'main', index: 0 }, { node: 'Send Error Email', type: 'main', index: 0 }], + ], + }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false); + }); + + it('should detect mixed success and error handlers in main[0]', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'API Request', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, + { id: '2', name: 'Transform Data', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + { id: '3', name: 'Store Data', type: 'n8n-nodes-base.set', position: [500, 100], parameters: {} }, + { id: '4', name: 'Error Notification', type: 'n8n-nodes-base.emailSend', position: [300, 300], parameters: {} }, + ], + connections: { + 'API Request': { + main: [[ + { node: 'Transform Data', type: 'main', index: 0 }, + { node: 'Store Data', type: 'main', index: 0 }, + { node: 'Error Notification', type: 'main', index: 0 }, + ]], + }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => + e.message.includes('Error Notification') && e.message.includes('appear to be error handlers but are in main[0]'), + )).toBe(true); + }); + + it('should handle nested error handling (error handlers with their own errors)', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'Primary API', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'continueErrorOutput' }, + { id: '2', name: 'Success Handler', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + { id: '3', name: 'Error Logger', type: 'n8n-nodes-base.httpRequest', position: [300, 200], parameters: {}, onError: 'continueErrorOutput' }, + { id: '4', name: 'Fallback Error', type: 'n8n-nodes-base.set', position: [500, 250], parameters: {} }, + ], + connections: { + 'Primary API': { main: [[{ node: 'Success Handler', type: 'main', index: 0 }], [{ node: 'Error Logger', type: 'main', index: 0 }]] }, + 'Error Logger': { main: [[], [{ node: 'Fallback Error', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false); + }); + + it('should handle workflows with only error outputs (no success path)', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'Risky Operation', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'continueErrorOutput' }, + { id: '2', name: 'Error Handler Only', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} }, + ], + connections: { + 'Risky Operation': { main: [[], [{ node: 'Error Handler Only', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false); + expect(result.errors.some(e => e.message.includes("has onError: 'continueErrorOutput' but no error output connections"))).toBe(false); + }); + + it('should not flag legitimate parallel processing nodes', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'Data Source', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, + { id: '2', name: 'Process A', type: 'n8n-nodes-base.set', position: [300, 50], parameters: {} }, + { id: '3', name: 'Process B', type: 'n8n-nodes-base.set', position: [300, 150], parameters: {} }, + { id: '4', name: 'Transform Data', type: 'n8n-nodes-base.set', position: [300, 250], parameters: {} }, + ], + connections: { + 'Data Source': { main: [[{ node: 'Process A', type: 'main', index: 0 }, { node: 'Process B', type: 'main', index: 0 }, { node: 'Transform Data', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false); + }); + + it('should detect all variations of error-related node names', async () => { + const workflow = { + nodes: [ + { id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, + { id: '2', name: 'Handle Failure', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + { id: '3', name: 'Catch Exception', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} }, + { id: '4', name: 'Success Path', type: 'n8n-nodes-base.set', position: [500, 100], parameters: {} }, + ], + connections: { + 'Source': { main: [[{ node: 'Handle Failure', type: 'main', index: 0 }, { node: 'Catch Exception', type: 'main', index: 0 }, { node: 'Success Path', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.errors.some(e => + e.message.includes('Handle Failure') && e.message.includes('Catch Exception') && e.message.includes('appear to be error handlers but are in main[0]'), + )).toBe(true); + }); + }); }); diff --git a/tests/unit/services/workflow-validator-edge-cases.test.ts b/tests/unit/services/workflow-validator-edge-cases.test.ts deleted file mode 100644 index c897344..0000000 --- a/tests/unit/services/workflow-validator-edge-cases.test.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; -import type { WorkflowValidationResult } from '@/services/workflow-validator'; - -// NOTE: Mocking EnhancedConfigValidator is challenging because: -// 1. WorkflowValidator expects the class itself, not an instance -// 2. The class has static methods that are called directly -// 3. vi.mock() hoisting makes it difficult to mock properly -// -// For properly mocked tests, see workflow-validator-with-mocks.test.ts -// These tests use a partially mocked approach that may still access the database - -// Mock dependencies -vi.mock('@/database/node-repository'); -vi.mock('@/services/expression-validator'); -vi.mock('@/utils/logger'); - -// Mock EnhancedConfigValidator with static methods -vi.mock('@/services/enhanced-config-validator', () => ({ - EnhancedConfigValidator: { - validate: vi.fn().mockReturnValue({ - valid: true, - errors: [], - warnings: [], - suggestions: [], - visibleProperties: [], - hiddenProperties: [] - }), - validateWithMode: vi.fn().mockReturnValue({ - valid: true, - errors: [], - warnings: [], - fixedConfig: null - }) - } -})); - -describe('WorkflowValidator - Edge Cases', () => { - let validator: WorkflowValidator; - let mockNodeRepository: any; - let mockEnhancedConfigValidator: any; - - beforeEach(() => { - vi.clearAllMocks(); - - // Create mock repository that returns node info for test nodes and common n8n nodes - mockNodeRepository = { - getNode: vi.fn().mockImplementation((type: string) => { - if (type === 'test.node' || type === 'test.agent' || type === 'test.tool') { - return { - name: 'Test Node', - type: type, - typeVersion: 1, - properties: [], - package: 'test-package', - version: 1, - displayName: 'Test Node', - isVersioned: false - }; - } - // Handle common n8n node types - if (type.startsWith('n8n-nodes-base.') || type.startsWith('nodes-base.')) { - const nodeName = type.split('.')[1]; - return { - name: nodeName, - type: type, - typeVersion: 1, - properties: [], - package: 'n8n-nodes-base', - version: 1, - displayName: nodeName.charAt(0).toUpperCase() + nodeName.slice(1), - isVersioned: ['set', 'httpRequest'].includes(nodeName) - }; - } - return null; - }), - findByType: vi.fn().mockReturnValue({ - name: 'Test Node', - type: 'test.node', - typeVersion: 1, - properties: [] - }), - searchNodes: vi.fn().mockReturnValue([]) - }; - - // Ensure EnhancedConfigValidator.validate always returns a valid result - vi.mocked(EnhancedConfigValidator.validate).mockReturnValue({ - valid: true, - errors: [], - warnings: [], - suggestions: [], - visibleProperties: [], - hiddenProperties: [] - }); - - // Create validator instance with mocked dependencies - validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); - }); - - describe('Null and Undefined Handling', () => { - it('should handle null workflow gracefully', async () => { - const result = await validator.validateWorkflow(null as any); - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true); - }); - - it('should handle undefined workflow gracefully', async () => { - const result = await validator.validateWorkflow(undefined as any); - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true); - }); - - it('should handle workflow with null nodes array', async () => { - const workflow = { - nodes: null, - connections: {} - }; - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('nodes must be an array'))).toBe(true); - }); - - it('should handle workflow with null connections', async () => { - const workflow = { - nodes: [], - connections: null - }; - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('connections must be an object'))).toBe(true); - }); - - it('should handle nodes with null/undefined properties', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: null, - type: 'test.node', - position: [0, 0], - parameters: undefined - } - ], - connections: {} - }; - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('Boundary Value Testing', () => { - it('should handle empty workflow', async () => { - const workflow = { - nodes: [], - connections: {} - }; - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(true); - expect(result.warnings.some(w => w.message.includes('empty'))).toBe(true); - }); - - it('should handle very large workflows', async () => { - const nodes = Array(1000).fill(null).map((_, i) => ({ - id: `node${i}`, - name: `Node ${i}`, - type: 'test.node', - position: [i * 100, 0] as [number, number], - parameters: {} - })); - - const connections: any = {}; - for (let i = 0; i < 999; i++) { - connections[`Node ${i}`] = { - main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]] - }; - } - - const workflow = { nodes, connections }; - - const start = Date.now(); - const result = await validator.validateWorkflow(workflow as any); - const duration = Date.now() - start; - - expect(result).toBeDefined(); - // Use longer timeout for CI environments - const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'; - const timeout = isCI ? 10000 : 5000; // 10 seconds for CI, 5 seconds for local - expect(duration).toBeLessThan(timeout); - }); - - it('should handle deeply nested connections', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Start', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'Middle', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }, - { id: '3', name: 'End', type: 'test.node', position: [200, 0] as [number, number], parameters: {} } - ], - connections: { - 'Start': { - main: [[{ node: 'Middle', type: 'main', index: 0 }]], - error: [[{ node: 'End', type: 'main', index: 0 }]], - ai_tool: [[{ node: 'Middle', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.statistics.invalidConnections).toBe(0); - }); - - it.skip('should handle nodes at extreme positions - FIXME: mock issues', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'FarLeft', type: 'n8n-nodes-base.set', position: [-999999, -999999] as [number, number], parameters: {} }, - { id: '2', name: 'FarRight', type: 'n8n-nodes-base.set', position: [999999, 999999] as [number, number], parameters: {} }, - { id: '3', name: 'Zero', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} } - ], - connections: { - 'FarLeft': { - main: [[{ node: 'FarRight', type: 'main', index: 0 }]] - }, - 'FarRight': { - main: [[{ node: 'Zero', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(true); - }); - }); - - describe('Invalid Data Type Handling', () => { - it('should handle non-array nodes', async () => { - const workflow = { - nodes: 'not-an-array', - connections: {} - }; - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(false); - expect(result.errors[0].message).toContain('nodes must be an array'); - }); - - it('should handle non-object connections', async () => { - const workflow = { - nodes: [], - connections: [] - }; - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(false); - expect(result.errors[0].message).toContain('connections must be an object'); - }); - - it('should handle invalid position values', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'InvalidPos', type: 'test.node', position: 'invalid' as any, parameters: {} }, - { id: '2', name: 'NaNPos', type: 'test.node', position: [NaN, NaN] as [number, number], parameters: {} }, - { id: '3', name: 'InfinityPos', type: 'test.node', position: [Infinity, -Infinity] as [number, number], parameters: {} } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should handle circular references in workflow object', async () => { - const workflow: any = { - nodes: [], - connections: {} - }; - workflow.circular = workflow; - - await expect(validator.validateWorkflow(workflow)).resolves.toBeDefined(); - }); - }); - - describe('Connection Validation Edge Cases', () => { - it('should detect self-referencing nodes', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'SelfLoop', type: 'test.node', position: [0, 0] as [number, number], parameters: {} } - ], - connections: { - 'SelfLoop': { - main: [[{ node: 'SelfLoop', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.warnings.some(w => w.message.includes('self-referencing'))).toBe(true); - }); - - it('should handle non-existent node references', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} } - ], - connections: { - 'Node1': { - main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.errors.some(e => e.message.includes('non-existent'))).toBe(true); - }); - - it('should handle invalid connection formats', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} } - ], - connections: { - 'Node1': { - main: 'invalid-format' as any - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should handle missing connection properties', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} } - ], - connections: { - 'Node1': { - main: [[{ node: 'Node2' }]] // Missing type and index - } - } as any - }; - - const result = await validator.validateWorkflow(workflow as any); - // Should still work as type and index can have defaults - expect(result.statistics.validConnections).toBeGreaterThan(0); - }); - - it('should handle negative output indices', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} } - ], - connections: { - 'Node1': { - main: [[{ node: 'Node2', type: 'main', index: -1 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.errors.some(e => e.message.includes('Invalid'))).toBe(true); - }); - }); - - describe('Special Characters and Unicode', () => { - // Note: These tests are skipped because WorkflowValidator also needs special character - // normalization (similar to WorkflowDiffEngine fix in #270). Will be addressed in a future PR. - it.skip('should handle apostrophes in node names - TODO: needs WorkflowValidator normalization', async () => { - // Test default n8n Manual Trigger node name with apostrophes - const workflow = { - nodes: [ - { id: '1', name: "When clicking 'Execute workflow'", type: 'n8n-nodes-base.manualTrigger', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 0] as [number, number], parameters: {} } - ], - connections: { - "When clicking 'Execute workflow'": { - main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it.skip('should handle special characters in node names - TODO: needs WorkflowValidator normalization', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Node@#$%', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'Node äø­ę–‡', type: 'n8n-nodes-base.set', position: [100, 0] as [number, number], parameters: {} }, - { id: '3', name: 'Node😊', type: 'n8n-nodes-base.set', position: [200, 0] as [number, number], parameters: {} } - ], - connections: { - 'Node@#$%': { - main: [[{ node: 'Node äø­ę–‡', type: 'main', index: 0 }]] - }, - 'Node äø­ę–‡': { - main: [[{ node: 'Node😊', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it('should handle very long node names', async () => { - const longName = 'A'.repeat(1000); - const workflow = { - nodes: [ - { id: '1', name: longName, type: 'test.node', position: [0, 0] as [number, number], parameters: {} } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.warnings.some(w => w.message.includes('very long'))).toBe(true); - }); - }); - - describe('Batch Validation', () => { - it.skip('should handle batch validation with mixed valid/invalid workflows - FIXME: mock issues', async () => { - const workflows = [ - { - nodes: [ - { id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [100, 0] as [number, number], parameters: {} } - ], - connections: { - 'Node1': { - main: [[{ node: 'Node2', type: 'main', index: 0 }]] - } - } - }, - null as any, - { - nodes: 'invalid' as any, - connections: {} - } - ]; - - const promises = workflows.map(w => validator.validateWorkflow(w)); - const results = await Promise.all(promises); - - expect(results[0].valid).toBe(true); - expect(results[1].valid).toBe(false); - expect(results[2].valid).toBe(false); - }); - - it.skip('should handle concurrent validation requests - FIXME: mock issues', async () => { - const workflow = { - nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: {} }], - connections: {} - }; - - const promises = Array(10).fill(null).map(() => validator.validateWorkflow(workflow)); - const results = await Promise.all(promises); - - expect(results.every(r => r.valid)).toBe(true); - }); - }); - - describe('Expression Validation Edge Cases', () => { - it('should skip expression validation when option is false', async () => { - const workflow = { - nodes: [{ - id: '1', - name: 'Node1', - type: 'test.node', - position: [0, 0] as [number, number], - parameters: { - value: '{{ $json.invalid.expression }}' - } - }], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow, { - validateExpressions: false - }); - - expect(result.statistics.expressionsValidated).toBe(0); - }); - }); - - describe('Connection Type Validation', () => { - it('should validate different connection types', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Agent', type: 'test.agent', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'Tool', type: 'test.tool', position: [100, 0] as [number, number], parameters: {} } - ], - connections: { - 'Tool': { - ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.statistics.validConnections).toBeGreaterThan(0); - }); - }); - - describe('Error Recovery', () => { - it('should continue validation after encountering errors', async () => { - const workflow = { - nodes: [ - { id: '1', name: null as any, type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'Valid', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }, - { id: '3', name: 'AlsoValid', type: 'test.node', position: [200, 0] as [number, number], parameters: {} } - ], - connections: { - 'Valid': { - main: [[{ node: 'AlsoValid', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.statistics.validConnections).toBeGreaterThan(0); - }); - }); - - describe('Static Method Alternatives', () => { - it('should validate workflow connections only', async () => { - const workflow = { - nodes: [ - { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, - { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} } - ], - connections: { - 'Node1': { - main: [[{ node: 'Node2', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow, { - validateNodes: false, - validateExpressions: false, - validateConnections: true - }); - - expect(result.statistics.validConnections).toBe(1); - }); - - it('should validate workflow expressions only', async () => { - const workflow = { - nodes: [{ - id: '1', - name: 'Node1', - type: 'test.node', - position: [0, 0] as [number, number], - parameters: { - value: '{{ $json.data }}' - } - }], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow, { - validateNodes: false, - validateExpressions: true, - validateConnections: false - }); - - expect(result.statistics.expressionsValidated).toBeGreaterThan(0); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-error-outputs.test.ts b/tests/unit/services/workflow-validator-error-outputs.test.ts deleted file mode 100644 index 335db60..0000000 --- a/tests/unit/services/workflow-validator-error-outputs.test.ts +++ /dev/null @@ -1,793 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -vi.mock('@/utils/logger'); - -describe('WorkflowValidator - Error Output Validation', () => { - let validator: WorkflowValidator; - let mockNodeRepository: any; - - beforeEach(() => { - vi.clearAllMocks(); - - // Create mock repository - mockNodeRepository = { - getNode: vi.fn((type: string) => { - // Return mock node info for common node types - if (type.includes('httpRequest') || type.includes('webhook') || type.includes('set')) { - return { - node_type: type, - display_name: 'Mock Node', - isVersioned: true, - version: 1 - }; - } - return null; - }) - }; - - validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); - }); - - describe('Error Output Configuration', () => { - it('should detect incorrect configuration - multiple nodes in same array', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Validate Input', - type: 'n8n-nodes-base.set', - typeVersion: 3.4, - position: [-400, 64], - parameters: {} - }, - { - id: '2', - name: 'Filter URLs', - type: 'n8n-nodes-base.filter', - typeVersion: 2.2, - position: [-176, 64], - parameters: {} - }, - { - id: '3', - name: 'Error Response1', - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1.5, - position: [-160, 240], - parameters: {} - } - ], - connections: { - 'Validate Input': { - main: [ - [ - { node: 'Filter URLs', type: 'main', index: 0 }, - { node: 'Error Response1', type: 'main', index: 0 } // WRONG! Both in main[0] - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.valid).toBe(false); - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') && - e.message.includes('Error Response1') && - e.message.includes('appear to be error handlers but are in main[0]') - )).toBe(true); - - // Check that the error message includes the fix - const errorMsg = result.errors.find(e => e.message.includes('Incorrect error output configuration')); - expect(errorMsg?.message).toContain('INCORRECT (current)'); - expect(errorMsg?.message).toContain('CORRECT (should be)'); - expect(errorMsg?.message).toContain('main[1] = error output'); - }); - - it('should validate correct configuration - separate arrays', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Validate Input', - type: 'n8n-nodes-base.set', - typeVersion: 3.4, - position: [-400, 64], - parameters: {}, - onError: 'continueErrorOutput' - }, - { - id: '2', - name: 'Filter URLs', - type: 'n8n-nodes-base.filter', - typeVersion: 2.2, - position: [-176, 64], - parameters: {} - }, - { - id: '3', - name: 'Error Response1', - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1.5, - position: [-160, 240], - parameters: {} - } - ], - connections: { - 'Validate Input': { - main: [ - [ - { node: 'Filter URLs', type: 'main', index: 0 } - ], - [ - { node: 'Error Response1', type: 'main', index: 0 } // Correctly in main[1] - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not have the specific error about incorrect configuration - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') - )).toBe(false); - }); - - it('should detect onError without error connections', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 4, - position: [100, 100], - parameters: {}, - onError: 'continueErrorOutput' // Has onError - }, - { - id: '2', - name: 'Process Data', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'HTTP Request': { - main: [ - [ - { node: 'Process Data', type: 'main', index: 0 } - ] - // No main[1] for error output - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => - e.nodeName === 'HTTP Request' && - e.message.includes("has onError: 'continueErrorOutput' but no error output connections") - )).toBe(true); - }); - - it('should warn about error connections without onError', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 4, - position: [100, 100], - parameters: {} - // Missing onError property - }, - { - id: '2', - name: 'Process Data', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Error Handler', - type: 'n8n-nodes-base.set', - position: [300, 300], - parameters: {} - } - ], - connections: { - 'HTTP Request': { - main: [ - [ - { node: 'Process Data', type: 'main', index: 0 } - ], - [ - { node: 'Error Handler', type: 'main', index: 0 } // Has error connection - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.warnings.some(w => - w.nodeName === 'HTTP Request' && - w.message.includes('error output connections in main[1] but missing onError') - )).toBe(true); - }); - }); - - describe('Error Handler Detection', () => { - it('should detect error handler nodes by name', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'API Call', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Process Success', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Handle Error', // Contains 'error' - type: 'n8n-nodes-base.set', - position: [300, 300], - parameters: {} - } - ], - connections: { - 'API Call': { - main: [ - [ - { node: 'Process Success', type: 'main', index: 0 }, - { node: 'Handle Error', type: 'main', index: 0 } // Wrong placement - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => - e.message.includes('Handle Error') && - e.message.includes('appear to be error handlers') - )).toBe(true); - }); - - it('should detect error handler nodes by type', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Process', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Respond', - type: 'n8n-nodes-base.respondToWebhook', // Common error handler type - position: [300, 300], - parameters: {} - } - ], - connections: { - 'Webhook': { - main: [ - [ - { node: 'Process', type: 'main', index: 0 }, - { node: 'Respond', type: 'main', index: 0 } // Wrong placement - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => - e.message.includes('Respond') && - e.message.includes('appear to be error handlers') - )).toBe(true); - }); - - it('should not flag non-error nodes in main[0]', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Start', - type: 'n8n-nodes-base.manualTrigger', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'First Process', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Second Process', - type: 'n8n-nodes-base.set', - position: [300, 200], - parameters: {} - } - ], - connections: { - 'Start': { - main: [ - [ - { node: 'First Process', type: 'main', index: 0 }, - { node: 'Second Process', type: 'main', index: 0 } // Both are valid success paths - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not have error about incorrect error configuration - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') - )).toBe(false); - }); - }); - - describe('Complex Error Patterns', () => { - it('should handle multiple error handlers correctly', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - onError: 'continueErrorOutput' - }, - { - id: '2', - name: 'Process', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Log Error', - type: 'n8n-nodes-base.set', - position: [300, 200], - parameters: {} - }, - { - id: '4', - name: 'Send Error Email', - type: 'n8n-nodes-base.emailSend', - position: [300, 300], - parameters: {} - } - ], - connections: { - 'HTTP Request': { - main: [ - [ - { node: 'Process', type: 'main', index: 0 } - ], - [ - { node: 'Log Error', type: 'main', index: 0 }, - { node: 'Send Error Email', type: 'main', index: 0 } // Multiple error handlers OK in main[1] - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not have errors about the configuration - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') - )).toBe(false); - }); - - it('should detect mixed success and error handlers in main[0]', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'API Request', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Transform Data', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Store Data', - type: 'n8n-nodes-base.set', - position: [500, 100], - parameters: {} - }, - { - id: '4', - name: 'Error Notification', - type: 'n8n-nodes-base.emailSend', - position: [300, 300], - parameters: {} - } - ], - connections: { - 'API Request': { - main: [ - [ - { node: 'Transform Data', type: 'main', index: 0 }, - { node: 'Store Data', type: 'main', index: 0 }, - { node: 'Error Notification', type: 'main', index: 0 } // Error handler mixed with success nodes - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - expect(result.errors.some(e => - e.message.includes('Error Notification') && - e.message.includes('appear to be error handlers but are in main[0]') - )).toBe(true); - }); - - it('should handle nested error handling (error handlers with their own errors)', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Primary API', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - onError: 'continueErrorOutput' - }, - { - id: '2', - name: 'Success Handler', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Error Logger', - type: 'n8n-nodes-base.httpRequest', - position: [300, 200], - parameters: {}, - onError: 'continueErrorOutput' - }, - { - id: '4', - name: 'Fallback Error', - type: 'n8n-nodes-base.set', - position: [500, 250], - parameters: {} - } - ], - connections: { - 'Primary API': { - main: [ - [ - { node: 'Success Handler', type: 'main', index: 0 } - ], - [ - { node: 'Error Logger', type: 'main', index: 0 } - ] - ] - }, - 'Error Logger': { - main: [ - [], - [ - { node: 'Fallback Error', type: 'main', index: 0 } - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not have errors about incorrect configuration - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') - )).toBe(false); - }); - }); - - describe('Edge Cases', () => { - it('should handle workflows with no connections at all', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Isolated Node', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {}, - onError: 'continueErrorOutput' - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should have warning about orphaned node but not error about connections - expect(result.warnings.some(w => - w.nodeName === 'Isolated Node' && - w.message.includes('not connected to any other nodes') - )).toBe(true); - - // Should not have error about error output configuration - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') - )).toBe(false); - }); - - it('should handle nodes with empty main arrays', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Source Node', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - onError: 'continueErrorOutput' - }, - { - id: '2', - name: 'Target Node', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Source Node': { - main: [ - [], // Empty success array - [] // Empty error array - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should detect that onError is set but no error connections exist - expect(result.errors.some(e => - e.nodeName === 'Source Node' && - e.message.includes("has onError: 'continueErrorOutput' but no error output connections") - )).toBe(true); - }); - - it('should handle workflows with only error outputs (no success path)', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Risky Operation', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {}, - onError: 'continueErrorOutput' - }, - { - id: '2', - name: 'Error Handler Only', - type: 'n8n-nodes-base.set', - position: [300, 200], - parameters: {} - } - ], - connections: { - 'Risky Operation': { - main: [ - [], // No success connections - [ - { node: 'Error Handler Only', type: 'main', index: 0 } - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not have errors about incorrect configuration - this is valid - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') - )).toBe(false); - - // Should not have errors about missing error connections - expect(result.errors.some(e => - e.message.includes("has onError: 'continueErrorOutput' but no error output connections") - )).toBe(false); - }); - - it('should handle undefined or null connection arrays gracefully', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Source Node', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {} - } - ], - connections: { - 'Source Node': { - main: [ - null, // Null array - undefined // Undefined array - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not crash and should not have configuration errors - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') - )).toBe(false); - }); - - it('should detect all variations of error-related node names', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Source', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Handle Failure', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - }, - { - id: '3', - name: 'Catch Exception', - type: 'n8n-nodes-base.set', - position: [300, 200], - parameters: {} - }, - { - id: '4', - name: 'Success Path', - type: 'n8n-nodes-base.set', - position: [500, 100], - parameters: {} - } - ], - connections: { - 'Source': { - main: [ - [ - { node: 'Handle Failure', type: 'main', index: 0 }, - { node: 'Catch Exception', type: 'main', index: 0 }, - { node: 'Success Path', type: 'main', index: 0 } - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should detect both 'Handle Failure' and 'Catch Exception' as error handlers - expect(result.errors.some(e => - e.message.includes('Handle Failure') && - e.message.includes('Catch Exception') && - e.message.includes('appear to be error handlers but are in main[0]') - )).toBe(true); - }); - - it('should not flag legitimate parallel processing nodes', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Data Source', - type: 'n8n-nodes-base.webhook', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Process A', - type: 'n8n-nodes-base.set', - position: [300, 50], - parameters: {} - }, - { - id: '3', - name: 'Process B', - type: 'n8n-nodes-base.set', - position: [300, 150], - parameters: {} - }, - { - id: '4', - name: 'Transform Data', - type: 'n8n-nodes-base.set', - position: [300, 250], - parameters: {} - } - ], - connections: { - 'Data Source': { - main: [ - [ - { node: 'Process A', type: 'main', index: 0 }, - { node: 'Process B', type: 'main', index: 0 }, - { node: 'Transform Data', type: 'main', index: 0 } - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not flag these as error configuration issues - expect(result.errors.some(e => - e.message.includes('Incorrect error output configuration') - )).toBe(false); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-expression-format.test.ts b/tests/unit/services/workflow-validator-expression-format.test.ts deleted file mode 100644 index 38328df..0000000 --- a/tests/unit/services/workflow-validator-expression-format.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { WorkflowValidator } from '../../../src/services/workflow-validator'; -import { NodeRepository } from '../../../src/database/node-repository'; -import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; - -// Mock the database -vi.mock('../../../src/database/node-repository'); - -describe('WorkflowValidator - Expression Format Validation', () => { - let validator: WorkflowValidator; - let mockNodeRepository: any; - - beforeEach(() => { - // Create mock repository - mockNodeRepository = { - findNodeByType: vi.fn().mockImplementation((type: string) => { - // Return mock nodes for common types - if (type === 'n8n-nodes-base.emailSend') { - return { - node_type: 'n8n-nodes-base.emailSend', - display_name: 'Email Send', - properties: {}, - version: 2.1 - }; - } - if (type === 'n8n-nodes-base.github') { - return { - node_type: 'n8n-nodes-base.github', - display_name: 'GitHub', - properties: {}, - version: 1.1 - }; - } - if (type === 'n8n-nodes-base.webhook') { - return { - node_type: 'n8n-nodes-base.webhook', - display_name: 'Webhook', - properties: {}, - version: 1 - }; - } - if (type === 'n8n-nodes-base.httpRequest') { - return { - node_type: 'n8n-nodes-base.httpRequest', - display_name: 'HTTP Request', - properties: {}, - version: 4 - }; - } - return null; - }), - searchNodes: vi.fn().mockReturnValue([]), - getAllNodes: vi.fn().mockReturnValue([]), - close: vi.fn() - }; - - validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); - }); - - describe('Expression Format Detection', () => { - it('should detect missing = prefix in simple expressions', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Send Email', - type: 'n8n-nodes-base.emailSend', - position: [0, 0] as [number, number], - parameters: { - fromEmail: '{{ $env.SENDER_EMAIL }}', - toEmail: 'user@example.com', - subject: 'Test Email' - }, - typeVersion: 2.1 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - expect(result.valid).toBe(false); - - // Find expression format errors - const formatErrors = result.errors.filter(e => e.message.includes('Expression format error')); - expect(formatErrors).toHaveLength(1); - - const error = formatErrors[0]; - expect(error.message).toContain('Expression format error'); - expect(error.message).toContain('fromEmail'); - expect(error.message).toContain('{{ $env.SENDER_EMAIL }}'); - expect(error.message).toContain('={{ $env.SENDER_EMAIL }}'); - }); - - it('should detect missing resource locator format for GitHub fields', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'GitHub', - type: 'n8n-nodes-base.github', - position: [0, 0] as [number, number], - parameters: { - operation: 'createComment', - owner: '{{ $vars.GITHUB_OWNER }}', - repository: '{{ $vars.GITHUB_REPO }}', - issueNumber: 123, - body: 'Test comment' - }, - typeVersion: 1.1 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - expect(result.valid).toBe(false); - // Should have errors for both owner and repository - const ownerError = result.errors.find(e => e.message.includes('owner')); - const repoError = result.errors.find(e => e.message.includes('repository')); - - expect(ownerError).toBeTruthy(); - expect(repoError).toBeTruthy(); - expect(ownerError?.message).toContain('resource locator format'); - expect(ownerError?.message).toContain('__rl'); - }); - - it('should detect mixed content without prefix', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0] as [number, number], - parameters: { - url: 'https://api.example.com/{{ $json.endpoint }}', - headers: { - Authorization: 'Bearer {{ $env.API_TOKEN }}' - } - }, - typeVersion: 4 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - expect(result.valid).toBe(false); - const errors = result.errors.filter(e => e.message.includes('Expression format')); - expect(errors.length).toBeGreaterThan(0); - - // Check for URL error - const urlError = errors.find(e => e.message.includes('url')); - expect(urlError).toBeTruthy(); - expect(urlError?.message).toContain('=https://api.example.com/{{ $json.endpoint }}'); - }); - - it('should accept properly formatted expressions', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Send Email', - type: 'n8n-nodes-base.emailSend', - position: [0, 0] as [number, number], - parameters: { - fromEmail: '={{ $env.SENDER_EMAIL }}', - toEmail: 'user@example.com', - subject: '=Test {{ $json.type }}' - }, - typeVersion: 2.1 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have no expression format errors - const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); - expect(formatErrors).toHaveLength(0); - }); - - it('should accept resource locator format', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'GitHub', - type: 'n8n-nodes-base.github', - position: [0, 0] as [number, number], - parameters: { - operation: 'createComment', - owner: { - __rl: true, - value: '={{ $vars.GITHUB_OWNER }}', - mode: 'expression' - }, - repository: { - __rl: true, - value: '={{ $vars.GITHUB_REPO }}', - mode: 'expression' - }, - issueNumber: 123, - body: '=Test comment from {{ $json.author }}' - }, - typeVersion: 1.1 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have no expression format errors - const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); - expect(formatErrors).toHaveLength(0); - }); - - it('should validate nested expressions in complex parameters', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0] as [number, number], - parameters: { - method: 'POST', - url: 'https://api.example.com', - sendBody: true, - bodyParameters: { - parameters: [ - { - name: 'userId', - value: '{{ $json.id }}' - }, - { - name: 'timestamp', - value: '={{ $now }}' - } - ] - } - }, - typeVersion: 4 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should detect the missing prefix in nested parameter - const errors = result.errors.filter(e => e.message.includes('Expression format')); - expect(errors.length).toBeGreaterThan(0); - - const nestedError = errors.find(e => e.message.includes('bodyParameters')); - expect(nestedError).toBeTruthy(); - }); - - it('should warn about RL format even with prefix', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'GitHub', - type: 'n8n-nodes-base.github', - position: [0, 0] as [number, number], - parameters: { - operation: 'createComment', - owner: '={{ $vars.GITHUB_OWNER }}', - repository: '={{ $vars.GITHUB_REPO }}', - issueNumber: 123, - body: 'Test' - }, - typeVersion: 1.1 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have warnings about using RL format - const warnings = result.warnings.filter(w => w.message.includes('resource locator format')); - expect(warnings.length).toBeGreaterThan(0); - }); - }); - - describe('Real-world workflow examples', () => { - it.skip('should validate Email workflow with expression issues', async () => { - const workflow = { - name: 'Error Notification Workflow', - nodes: [ - { - id: 'webhook-1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - position: [250, 300] as [number, number], - parameters: { - path: 'error-handler', - httpMethod: 'POST' - }, - typeVersion: 1 - }, - { - id: 'email-1', - name: 'Error Handler', - type: 'n8n-nodes-base.emailSend', - position: [450, 300] as [number, number], - parameters: { - fromEmail: '{{ $env.ADMIN_EMAIL }}', - toEmail: 'admin@company.com', - subject: 'Error in {{ $json.workflow }}', - message: 'An error occurred: {{ $json.error }}', - options: { - replyTo: '={{ $env.SUPPORT_EMAIL }}' - } - }, - typeVersion: 2.1 - } - ], - connections: { - 'Webhook': { - main: [[{ node: 'Error Handler', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have multiple expression format errors - const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); - expect(formatErrors.length).toBeGreaterThanOrEqual(3); // fromEmail, subject, message - - // Check specific errors - const fromEmailError = formatErrors.find(e => e.message.includes('fromEmail')); - expect(fromEmailError).toBeTruthy(); - expect(fromEmailError?.message).toContain('={{ $env.ADMIN_EMAIL }}'); - }); - - it.skip('should validate GitHub workflow with resource locator issues', async () => { - const workflow = { - name: 'GitHub Issue Handler', - nodes: [ - { - id: 'webhook-1', - name: 'Issue Webhook', - type: 'n8n-nodes-base.webhook', - position: [250, 300] as [number, number], - parameters: { - path: 'github-issue', - httpMethod: 'POST' - }, - typeVersion: 1 - }, - { - id: 'github-1', - name: 'Create Comment', - type: 'n8n-nodes-base.github', - position: [450, 300] as [number, number], - parameters: { - operation: 'createComment', - owner: '{{ $vars.GITHUB_OWNER }}', - repository: '{{ $vars.GITHUB_REPO }}', - issueNumber: '={{ $json.body.issue.number }}', - body: 'Thanks for the issue @{{ $json.body.issue.user.login }}!' - }, - typeVersion: 1.1 - } - ], - connections: { - 'Issue Webhook': { - main: [[{ node: 'Create Comment', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have errors for owner, repository, and body - const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); - expect(formatErrors.length).toBeGreaterThanOrEqual(3); - - // Check for resource locator suggestions - const ownerError = formatErrors.find(e => e.message.includes('owner')); - expect(ownerError?.message).toContain('__rl'); - expect(ownerError?.message).toContain('resource locator format'); - }); - - it('should provide clear fix examples in error messages', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Process Data', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0] as [number, number], - parameters: { - url: 'https://api.example.com/users/{{ $json.userId }}' - }, - typeVersion: 4 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - const error = result.errors.find(e => e.message.includes('Expression format')); - expect(error).toBeTruthy(); - - // Error message should contain both incorrect and correct examples - expect(error?.message).toContain('Current (incorrect):'); - expect(error?.message).toContain('"url": "https://api.example.com/users/{{ $json.userId }}"'); - expect(error?.message).toContain('Fixed (correct):'); - expect(error?.message).toContain('"url": "=https://api.example.com/users/{{ $json.userId }}"'); - }); - }); - - describe('Integration with other validations', () => { - it('should validate expression format alongside syntax', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'Test Node', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0] as [number, number], - parameters: { - url: '{{ $json.url', // Syntax error: unclosed expression - headers: { - 'X-Token': '{{ $env.TOKEN }}' // Format error: missing prefix - } - }, - typeVersion: 4 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have both syntax and format errors - const syntaxErrors = result.errors.filter(e => e.message.includes('Unmatched expression brackets')); - const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); - - expect(syntaxErrors.length).toBeGreaterThan(0); - expect(formatErrors.length).toBeGreaterThan(0); - }); - - it('should not interfere with node validation', async () => { - // Test that expression format validation works alongside other validations - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0] as [number, number], - parameters: { - url: '{{ $json.endpoint }}', // Expression format error - headers: { - Authorization: '={{ $env.TOKEN }}' // Correct format - } - }, - typeVersion: 4 - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have expression format error for url field - const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); - expect(formatErrors).toHaveLength(1); - expect(formatErrors[0].message).toContain('url'); - - // The workflow should still have structure validation (no trigger warning, etc) - // This proves that expression validation doesn't interfere with other checks - expect(result.warnings.some(w => w.message.includes('trigger'))).toBe(true); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-loops-simple.test.ts b/tests/unit/services/workflow-validator-loops-simple.test.ts deleted file mode 100644 index a9c4339..0000000 --- a/tests/unit/services/workflow-validator-loops-simple.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -// Mock dependencies -vi.mock('@/database/node-repository'); -vi.mock('@/services/enhanced-config-validator'); - -describe('WorkflowValidator - SplitInBatches Validation (Simplified)', () => { - let validator: WorkflowValidator; - let mockNodeRepository: any; - let mockNodeValidator: any; - - beforeEach(() => { - vi.clearAllMocks(); - - mockNodeRepository = { - getNode: vi.fn() - }; - - mockNodeValidator = { - validateWithMode: vi.fn().mockReturnValue({ - errors: [], - warnings: [] - }) - }; - - validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator); - }); - - describe('SplitInBatches node detection', () => { - it('should identify SplitInBatches nodes in workflow', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.splitInBatches', - properties: [] - }); - - const workflow = { - name: 'SplitInBatches Workflow', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: { batchSize: 10 } - }, - { - id: '2', - name: 'Process Item', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [], // Done output (0) - [{ node: 'Process Item', type: 'main', index: 0 }] // Loop output (1) - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should complete validation without crashing - expect(result).toBeDefined(); - expect(result.valid).toBeDefined(); - }); - - it('should handle SplitInBatches with processing node name patterns', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.splitInBatches', - properties: [] - }); - - const processingNames = [ - 'Process Item', - 'Transform Data', - 'Handle Each', - 'Function Node', - 'Code Block' - ]; - - for (const nodeName of processingNames) { - const workflow = { - name: 'Processing Pattern Test', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: nodeName, - type: 'n8n-nodes-base.function', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [{ node: nodeName, type: 'main', index: 0 }], // Processing node on Done output - [] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should identify potential processing nodes - expect(result).toBeDefined(); - } - }); - - it('should handle final processing node patterns', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.splitInBatches', - properties: [] - }); - - const finalNames = [ - 'Final Summary', - 'Send Email', - 'Complete Notification', - 'Final Report' - ]; - - for (const nodeName of finalNames) { - const workflow = { - name: 'Final Pattern Test', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: nodeName, - type: 'n8n-nodes-base.emailSend', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [{ node: nodeName, type: 'main', index: 0 }], // Final node on Done output (correct) - [] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not warn about final nodes on done output - expect(result).toBeDefined(); - } - }); - }); - - describe('Connection validation', () => { - it('should validate connection indices', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.splitInBatches', - properties: [] - }); - - const workflow = { - name: 'Connection Index Test', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'Target', - type: 'n8n-nodes-base.set', - position: [300, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [{ node: 'Target', type: 'main', index: -1 }] // Invalid negative index - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - const negativeIndexErrors = result.errors.filter(e => - e.message?.includes('Invalid connection index -1') - ); - expect(negativeIndexErrors.length).toBeGreaterThan(0); - }); - - it('should handle non-existent target nodes', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.splitInBatches', - properties: [] - }); - - const workflow = { - name: 'Missing Target Test', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [{ node: 'NonExistentNode', type: 'main', index: 0 }] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - const missingNodeErrors = result.errors.filter(e => - e.message?.includes('non-existent node') - ); - expect(missingNodeErrors.length).toBeGreaterThan(0); - }); - }); - - describe('Self-referencing connections', () => { - it('should allow self-referencing for SplitInBatches nodes', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.splitInBatches', - properties: [] - }); - - const workflow = { - name: 'Self Reference Test', - nodes: [ - { - id: '1', - name: 'Split In Batches', - type: 'n8n-nodes-base.splitInBatches', - position: [100, 100], - parameters: {} - } - ], - connections: { - 'Split In Batches': { - main: [ - [], - [{ node: 'Split In Batches', type: 'main', index: 0 }] // Self-reference on loop output - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should not warn about self-reference for SplitInBatches - const selfRefWarnings = result.warnings.filter(w => - w.message?.includes('self-referencing') - ); - expect(selfRefWarnings).toHaveLength(0); - }); - - it('should warn about self-referencing for non-loop nodes', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.set', - properties: [] - }); - - const workflow = { - name: 'Non-Loop Self Reference Test', - nodes: [ - { - id: '1', - name: 'Set', - type: 'n8n-nodes-base.set', - position: [100, 100], - parameters: {} - } - ], - connections: { - 'Set': { - main: [ - [{ node: 'Set', type: 'main', index: 0 }] // Self-reference on regular node - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should warn about self-reference for non-loop nodes - const selfRefWarnings = result.warnings.filter(w => - w.message?.includes('self-referencing') - ); - expect(selfRefWarnings.length).toBeGreaterThan(0); - }); - }); - - describe('Output connection validation', () => { - it('should validate output connections for nodes with outputs', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.if', - outputs: [ - { displayName: 'True', description: 'Items that match condition' }, - { displayName: 'False', description: 'Items that do not match condition' } - ], - outputNames: ['true', 'false'], - properties: [] - }); - - const workflow = { - name: 'IF Node Test', - nodes: [ - { - id: '1', - name: 'IF', - type: 'n8n-nodes-base.if', - position: [100, 100], - parameters: {} - }, - { - id: '2', - name: 'True Handler', - type: 'n8n-nodes-base.set', - position: [300, 50], - parameters: {} - }, - { - id: '3', - name: 'False Handler', - type: 'n8n-nodes-base.set', - position: [300, 150], - parameters: {} - } - ], - connections: { - 'IF': { - main: [ - [{ node: 'True Handler', type: 'main', index: 0 }], // True output (0) - [{ node: 'False Handler', type: 'main', index: 0 }] // False output (1) - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should validate without major errors - expect(result).toBeDefined(); - expect(result.statistics.validConnections).toBe(2); - }); - }); - - describe('Error handling', () => { - it('should handle nodes without outputs gracefully', async () => { - mockNodeRepository.getNode.mockReturnValue({ - nodeType: 'nodes-base.httpRequest', - outputs: null, - outputNames: null, - properties: [] - }); - - const workflow = { - name: 'No Outputs Test', - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should handle gracefully without crashing - expect(result).toBeDefined(); - }); - - it('should handle unknown node types gracefully', async () => { - mockNodeRepository.getNode.mockReturnValue(null); - - const workflow = { - name: 'Unknown Node Test', - nodes: [ - { - id: '1', - name: 'Unknown', - type: 'n8n-nodes-base.unknown', - position: [100, 100], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should report unknown node error - const unknownErrors = result.errors.filter(e => - e.message?.includes('Unknown node type') - ); - expect(unknownErrors.length).toBeGreaterThan(0); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-loops.test.ts b/tests/unit/services/workflow-validator-loops.test.ts index fdeb18c..9a69151 100644 --- a/tests/unit/services/workflow-validator-loops.test.ts +++ b/tests/unit/services/workflow-validator-loops.test.ts @@ -702,4 +702,244 @@ describe('WorkflowValidator - Loop Node Validation', () => { expect(result).toBeDefined(); }); }); + + // ─── Loop Output Edge Cases (absorbed from loop-output-edge-cases) ── + + describe('Nodes without outputs', () => { + it('should handle nodes with null outputs gracefully', async () => { + mockNodeRepository.getNode.mockReturnValue({ + nodeType: 'nodes-base.httpRequest', outputs: null, outputNames: null, properties: [], + }); + + const workflow = { + name: 'No Outputs', + nodes: [ + { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: { url: 'https://example.com' } }, + { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + ], + connections: { 'HTTP Request': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result).toBeDefined(); + const outputErrors = result.errors.filter(e => e.message?.includes('output') && !e.message?.includes('Connection')); + expect(outputErrors).toHaveLength(0); + }); + + it('should handle nodes with empty outputs array', async () => { + mockNodeRepository.getNode.mockReturnValue({ + nodeType: 'nodes-base.customNode', outputs: [], outputNames: [], properties: [], + }); + + const workflow = { + name: 'Empty Outputs', + nodes: [{ id: '1', name: 'Custom Node', type: 'n8n-nodes-base.customNode', position: [100, 100], parameters: {} }], + connections: { 'Custom Node': { main: [[{ node: 'Custom Node', type: 'main', index: 0 }]] } }, + }; + + const result = await validator.validateWorkflow(workflow as any); + const selfRefWarnings = result.warnings.filter(w => w.message?.includes('self-referencing')); + expect(selfRefWarnings).toHaveLength(1); + }); + }); + + describe('Invalid connection indices', () => { + it('should handle very large connection indices', async () => { + mockNodeRepository.getNode.mockReturnValue({ + nodeType: 'nodes-base.switch', outputs: [{ displayName: 'Output 1' }, { displayName: 'Output 2' }], properties: [], + }); + + const workflow = { + name: 'Large Index', + nodes: [ + { id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', position: [100, 100], parameters: {} }, + { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + ], + connections: { 'Switch': { main: [[{ node: 'Set', type: 'main', index: 999 }]] } }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result).toBeDefined(); + }); + }); + + describe('Malformed connection structures', () => { + it('should handle null connection objects', async () => { + const workflow = { + name: 'Null Connections', + nodes: [{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }], + connections: { 'Split In Batches': { main: [null, [{ node: 'NonExistent', type: 'main', index: 0 }]] as any } }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result).toBeDefined(); + }); + + it('should handle missing connection properties', async () => { + const workflow = { + name: 'Malformed Connections', + nodes: [ + { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }, + { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + ], + connections: { + 'Split In Batches': { main: [[{ node: 'Set' } as any, { type: 'main', index: 0 } as any, {} as any]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result).toBeDefined(); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('Complex output structures', () => { + it('should handle nodes with many outputs', async () => { + const manyOutputs = Array.from({ length: 20 }, (_, i) => ({ + displayName: `Output ${i + 1}`, name: `output${i + 1}`, + })); + + mockNodeRepository.getNode.mockReturnValue({ + nodeType: 'nodes-base.complexSwitch', outputs: manyOutputs, outputNames: manyOutputs.map(o => o.name), properties: [], + }); + + const workflow = { + name: 'Many Outputs', + nodes: [ + { id: '1', name: 'Complex Switch', type: 'n8n-nodes-base.complexSwitch', position: [100, 100], parameters: {} }, + { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + ], + connections: { 'Complex Switch': { main: Array.from({ length: 20 }, () => [{ node: 'Set', type: 'main', index: 0 }]) } }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result).toBeDefined(); + }); + + it('should handle mixed output types (main, error, ai_tool)', async () => { + mockNodeRepository.getNode.mockReturnValue({ + nodeType: 'nodes-base.complexNode', outputs: [{ displayName: 'Main', type: 'main' }, { displayName: 'Error', type: 'error' }], properties: [], + }); + + const workflow = { + name: 'Mixed Output Types', + nodes: [ + { id: '1', name: 'Complex Node', type: 'n8n-nodes-base.complexNode', position: [100, 100], parameters: {} }, + { id: '2', name: 'Main Handler', type: 'n8n-nodes-base.set', position: [300, 50], parameters: {} }, + { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 150], parameters: {} }, + { id: '4', name: 'Tool', type: 'n8n-nodes-base.httpRequest', position: [500, 100], parameters: {} }, + ], + connections: { + 'Complex Node': { + main: [[{ node: 'Main Handler', type: 'main', index: 0 }]], + error: [[{ node: 'Error Handler', type: 'main', index: 0 }]], + ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]], + }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result).toBeDefined(); + expect(result.statistics.validConnections).toBe(3); + }); + }); + + describe('SplitInBatches specific edge cases', () => { + it('should handle SplitInBatches with no connections', async () => { + const workflow = { + name: 'Isolated SplitInBatches', + nodes: [{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }], + connections: {}, + }; + + const result = await validator.validateWorkflow(workflow as any); + const splitWarnings = result.warnings.filter(w => w.message?.includes('SplitInBatches') || w.message?.includes('loop') || w.message?.includes('done')); + expect(splitWarnings).toHaveLength(0); + }); + + it('should handle SplitInBatches with only done output connected', async () => { + const workflow = { + name: 'Single Output SplitInBatches', + nodes: [ + { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }, + { id: '2', name: 'Final Action', type: 'n8n-nodes-base.emailSend', position: [300, 100], parameters: {} }, + ], + connections: { 'Split In Batches': { main: [[{ node: 'Final Action', type: 'main', index: 0 }], []] } }, + }; + + const result = await validator.validateWorkflow(workflow as any); + const loopWarnings = result.warnings.filter(w => w.message?.includes('loop') && w.message?.includes('connect back')); + expect(loopWarnings).toHaveLength(0); + }); + + it('should handle SplitInBatches with both outputs to same node', async () => { + const workflow = { + name: 'Same Target SplitInBatches', + nodes: [ + { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }, + { id: '2', name: 'Multi Purpose', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + ], + connections: { + 'Split In Batches': { main: [[{ node: 'Multi Purpose', type: 'main', index: 0 }], [{ node: 'Multi Purpose', type: 'main', index: 0 }]] }, + 'Multi Purpose': { main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + const loopWarnings = result.warnings.filter(w => w.message?.includes('loop') && w.message?.includes('connect back')); + expect(loopWarnings).toHaveLength(0); + }); + + it('should detect reversed outputs with processing node on done output', async () => { + const workflow = { + name: 'Reversed SplitInBatches with Function Node', + nodes: [ + { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }, + { id: '2', name: 'Process Function', type: 'n8n-nodes-base.function', position: [300, 100], parameters: {} }, + ], + connections: { + 'Split In Batches': { main: [[{ node: 'Process Function', type: 'main', index: 0 }], []] }, + 'Process Function': { main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + const reversedErrors = result.errors.filter(e => e.message?.includes('SplitInBatches outputs appear reversed')); + expect(reversedErrors).toHaveLength(1); + }); + + it('should handle self-referencing nodes in loop back detection', async () => { + const workflow = { + name: 'Self Reference in Loop Back', + nodes: [ + { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }, + { id: '2', name: 'SelfRef', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, + ], + connections: { + 'Split In Batches': { main: [[], [{ node: 'SelfRef', type: 'main', index: 0 }]] }, + 'SelfRef': { main: [[{ node: 'SelfRef', type: 'main', index: 0 }]] }, + }, + }; + + const result = await validator.validateWorkflow(workflow as any); + expect(result.warnings.filter(w => w.message?.includes("doesn't connect back"))).toHaveLength(1); + expect(result.warnings.filter(w => w.message?.includes('self-referencing'))).toHaveLength(1); + }); + + it('should handle many SplitInBatches nodes', async () => { + const nodes = Array.from({ length: 100 }, (_, i) => ({ + id: `split${i}`, name: `Split ${i}`, type: 'n8n-nodes-base.splitInBatches', + position: [100 + (i % 10) * 100, 100 + Math.floor(i / 10) * 100], parameters: {}, + })); + + const connections: any = {}; + for (let i = 0; i < 99; i++) { + connections[`Split ${i}`] = { main: [[{ node: `Split ${i + 1}`, type: 'main', index: 0 }], []] }; + } + + const result = await validator.validateWorkflow({ name: 'Many SplitInBatches', nodes, connections } as any); + expect(result).toBeDefined(); + expect(result.statistics.totalNodes).toBe(100); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-mocks.test.ts b/tests/unit/services/workflow-validator-mocks.test.ts deleted file mode 100644 index 88c71af..0000000 --- a/tests/unit/services/workflow-validator-mocks.test.ts +++ /dev/null @@ -1,721 +0,0 @@ -import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -vi.mock('@/utils/logger'); - -describe('WorkflowValidator - Mock-based Unit Tests', () => { - let validator: WorkflowValidator; - let mockNodeRepository: any; - let mockGetNode: Mock; - - beforeEach(() => { - vi.clearAllMocks(); - - // Create detailed mock repository with spy functions - mockGetNode = vi.fn(); - mockNodeRepository = { - getNode: mockGetNode - }; - - validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); - - // Default mock responses - mockGetNode.mockImplementation((type: string) => { - if (type.includes('httpRequest')) { - return { - node_type: type, - display_name: 'HTTP Request', - isVersioned: true, - version: 4 - }; - } else if (type.includes('set')) { - return { - node_type: type, - display_name: 'Set', - isVersioned: true, - version: 3 - }; - } else if (type.includes('respondToWebhook')) { - return { - node_type: type, - display_name: 'Respond to Webhook', - isVersioned: true, - version: 1 - }; - } - return null; - }); - }); - - describe('Error Handler Detection Logic', () => { - it('should correctly identify error handlers by node name patterns', async () => { - const errorNodeNames = [ - 'Error Handler', - 'Handle Error', - 'Catch Exception', - 'Failure Response', - 'Error Notification', - 'Fail Safe', - 'Exception Handler', - 'Error Callback' - ]; - - const successNodeNames = [ - 'Process Data', - 'Transform', - 'Success Handler', - 'Continue Process', - 'Normal Flow' - ]; - - for (const errorName of errorNodeNames) { - const workflow = { - nodes: [ - { - id: '1', - name: 'Source', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - }, - { - id: '2', - name: 'Success Path', - type: 'n8n-nodes-base.set', - position: [200, 0], - parameters: {} - }, - { - id: '3', - name: errorName, - type: 'n8n-nodes-base.set', - position: [200, 100], - parameters: {} - } - ], - connections: { - 'Source': { - main: [ - [ - { node: 'Success Path', type: 'main', index: 0 }, - { node: errorName, type: 'main', index: 0 } // Should be detected as error handler - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should detect this as an incorrect error configuration - const hasError = result.errors.some(e => - e.message.includes('Incorrect error output configuration') && - e.message.includes(errorName) - ); - expect(hasError).toBe(true); - } - - // Test that success node names are NOT flagged - for (const successName of successNodeNames) { - const workflow = { - nodes: [ - { - id: '1', - name: 'Source', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - }, - { - id: '2', - name: 'First Process', - type: 'n8n-nodes-base.set', - position: [200, 0], - parameters: {} - }, - { - id: '3', - name: successName, - type: 'n8n-nodes-base.set', - position: [200, 100], - parameters: {} - } - ], - connections: { - 'Source': { - main: [ - [ - { node: 'First Process', type: 'main', index: 0 }, - { node: successName, type: 'main', index: 0 } - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should NOT detect this as an error configuration - const hasError = result.errors.some(e => - e.message.includes('Incorrect error output configuration') - ); - expect(hasError).toBe(false); - } - }); - - it('should correctly identify error handlers by node type patterns', async () => { - const errorNodeTypes = [ - 'n8n-nodes-base.respondToWebhook', - 'n8n-nodes-base.emailSend' - // Note: slack and webhook are not in the current detection logic - ]; - - // Update mock to return appropriate node info for these types - mockGetNode.mockImplementation((type: string) => { - return { - node_type: type, - display_name: type.split('.').pop() || 'Unknown', - isVersioned: true, - version: 1 - }; - }); - - for (const nodeType of errorNodeTypes) { - const workflow = { - nodes: [ - { - id: '1', - name: 'Source', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - }, - { - id: '2', - name: 'Success Path', - type: 'n8n-nodes-base.set', - position: [200, 0], - parameters: {} - }, - { - id: '3', - name: 'Response Node', - type: nodeType, - position: [200, 100], - parameters: {} - } - ], - connections: { - 'Source': { - main: [ - [ - { node: 'Success Path', type: 'main', index: 0 }, - { node: 'Response Node', type: 'main', index: 0 } // Should be detected - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should detect this as an incorrect error configuration - const hasError = result.errors.some(e => - e.message.includes('Incorrect error output configuration') && - e.message.includes('Response Node') - ); - expect(hasError).toBe(true); - } - }); - - it('should handle cases where node repository returns null', async () => { - // Mock repository to return null for unknown nodes - mockGetNode.mockImplementation((type: string) => { - if (type === 'n8n-nodes-base.unknownNode') { - return null; - } - return { - node_type: type, - display_name: 'Known Node', - isVersioned: true, - version: 1 - }; - }); - - const workflow = { - nodes: [ - { - id: '1', - name: 'Source', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - }, - { - id: '2', - name: 'Unknown Node', - type: 'n8n-nodes-base.unknownNode', - position: [200, 0], - parameters: {} - }, - { - id: '3', - name: 'Error Handler', - type: 'n8n-nodes-base.set', - position: [200, 100], - parameters: {} - } - ], - connections: { - 'Source': { - main: [ - [ - { node: 'Unknown Node', type: 'main', index: 0 }, - { node: 'Error Handler', type: 'main', index: 0 } - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - // Should still detect the error configuration based on node name - const hasError = result.errors.some(e => - e.message.includes('Incorrect error output configuration') && - e.message.includes('Error Handler') - ); - expect(hasError).toBe(true); - - // Should not crash due to null node info - expect(result).toHaveProperty('valid'); - expect(Array.isArray(result.errors)).toBe(true); - }); - }); - - describe('onError Property Validation Logic', () => { - it('should validate onError property combinations correctly', async () => { - const testCases = [ - { - name: 'onError set but no error connections', - onError: 'continueErrorOutput', - hasErrorConnections: false, - expectedErrorType: 'error', - expectedMessage: "has onError: 'continueErrorOutput' but no error output connections" - }, - { - name: 'error connections but no onError', - onError: undefined, - hasErrorConnections: true, - expectedErrorType: 'warning', - expectedMessage: 'error output connections in main[1] but missing onError' - }, - { - name: 'onError set with error connections', - onError: 'continueErrorOutput', - hasErrorConnections: true, - expectedErrorType: null, - expectedMessage: null - }, - { - name: 'no onError and no error connections', - onError: undefined, - hasErrorConnections: false, - expectedErrorType: null, - expectedMessage: null - } - ]; - - for (const testCase of testCases) { - const workflow = { - nodes: [ - { - id: '1', - name: 'Test Node', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {}, - ...(testCase.onError ? { onError: testCase.onError } : {}) - }, - { - id: '2', - name: 'Success Handler', - type: 'n8n-nodes-base.set', - position: [200, 0], - parameters: {} - }, - { - id: '3', - name: 'Error Handler', - type: 'n8n-nodes-base.set', - position: [200, 100], - parameters: {} - } - ], - connections: { - 'Test Node': { - main: [ - [ - { node: 'Success Handler', type: 'main', index: 0 } - ], - ...(testCase.hasErrorConnections ? [ - [ - { node: 'Error Handler', type: 'main', index: 0 } - ] - ] : []) - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - if (testCase.expectedErrorType === 'error') { - const hasExpectedError = result.errors.some(e => - e.nodeName === 'Test Node' && - e.message.includes(testCase.expectedMessage!) - ); - expect(hasExpectedError).toBe(true); - } else if (testCase.expectedErrorType === 'warning') { - const hasExpectedWarning = result.warnings.some(w => - w.nodeName === 'Test Node' && - w.message.includes(testCase.expectedMessage!) - ); - expect(hasExpectedWarning).toBe(true); - } else { - // Should not have related errors or warnings about onError/error output mismatches - const hasRelatedError = result.errors.some(e => - e.nodeName === 'Test Node' && - (e.message.includes("has onError: 'continueErrorOutput' but no error output connections") || - e.message.includes('Incorrect error output configuration')) - ); - const hasRelatedWarning = result.warnings.some(w => - w.nodeName === 'Test Node' && - w.message.includes('error output connections in main[1] but missing onError') - ); - expect(hasRelatedError).toBe(false); - expect(hasRelatedWarning).toBe(false); - } - } - }); - - it('should handle different onError values correctly', async () => { - const onErrorValues = [ - 'continueErrorOutput', - 'continueRegularOutput', - 'stopWorkflow' - ]; - - for (const onErrorValue of onErrorValues) { - const workflow = { - nodes: [ - { - id: '1', - name: 'Test Node', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {}, - onError: onErrorValue - }, - { - id: '2', - name: 'Next Node', - type: 'n8n-nodes-base.set', - position: [200, 0], - parameters: {} - } - ], - connections: { - 'Test Node': { - main: [ - [ - { node: 'Next Node', type: 'main', index: 0 } - ] - // No error connections - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - if (onErrorValue === 'continueErrorOutput') { - // Should have error about missing error connections - const hasError = result.errors.some(e => - e.nodeName === 'Test Node' && - e.message.includes("has onError: 'continueErrorOutput' but no error output connections") - ); - expect(hasError).toBe(true); - } else { - // Should not have error about missing error connections - const hasError = result.errors.some(e => - e.nodeName === 'Test Node' && - e.message.includes('but no error output connections') - ); - expect(hasError).toBe(false); - } - } - }); - }); - - describe('JSON Format Generation', () => { - it('should generate valid JSON in error messages', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'API Call', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - }, - { - id: '2', - name: 'Success Process', - type: 'n8n-nodes-base.set', - position: [200, 0], - parameters: {} - }, - { - id: '3', - name: 'Error Handler', - type: 'n8n-nodes-base.respondToWebhook', - position: [200, 100], - parameters: {} - } - ], - connections: { - 'API Call': { - main: [ - [ - { node: 'Success Process', type: 'main', index: 0 }, - { node: 'Error Handler', type: 'main', index: 0 } - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - const errorConfigError = result.errors.find(e => - e.message.includes('Incorrect error output configuration') - ); - - expect(errorConfigError).toBeDefined(); - - // Extract JSON sections from error message - const incorrectMatch = errorConfigError!.message.match(/INCORRECT \(current\):\n([\s\S]*?)\n\nCORRECT/); - const correctMatch = errorConfigError!.message.match(/CORRECT \(should be\):\n([\s\S]*?)\n\nAlso add/); - - expect(incorrectMatch).toBeDefined(); - expect(correctMatch).toBeDefined(); - - // Extract just the JSON part (remove comments) - const incorrectJsonStr = incorrectMatch![1]; - const correctJsonStr = correctMatch![1]; - - // Remove comments and clean up for JSON parsing - const cleanIncorrectJson = incorrectJsonStr.replace(/\/\/.*$/gm, '').replace(/,\s*$/, ''); - const cleanCorrectJson = correctJsonStr.replace(/\/\/.*$/gm, '').replace(/,\s*$/, ''); - - const incorrectJson = `{${cleanIncorrectJson}}`; - const correctJson = `{${cleanCorrectJson}}`; - - expect(() => JSON.parse(incorrectJson)).not.toThrow(); - expect(() => JSON.parse(correctJson)).not.toThrow(); - - const parsedIncorrect = JSON.parse(incorrectJson); - const parsedCorrect = JSON.parse(correctJson); - - // Validate structure - expect(parsedIncorrect).toHaveProperty('API Call'); - expect(parsedCorrect).toHaveProperty('API Call'); - expect(parsedIncorrect['API Call']).toHaveProperty('main'); - expect(parsedCorrect['API Call']).toHaveProperty('main'); - - // Incorrect should have both nodes in main[0] - expect(Array.isArray(parsedIncorrect['API Call'].main)).toBe(true); - expect(parsedIncorrect['API Call'].main).toHaveLength(1); - expect(parsedIncorrect['API Call'].main[0]).toHaveLength(2); - - // Correct should have separate arrays - expect(Array.isArray(parsedCorrect['API Call'].main)).toBe(true); - expect(parsedCorrect['API Call'].main).toHaveLength(2); - expect(parsedCorrect['API Call'].main[0]).toHaveLength(1); // Success only - expect(parsedCorrect['API Call'].main[1]).toHaveLength(1); // Error only - }); - - it('should handle special characters in node names in JSON', async () => { - // Test simpler special characters that are easier to handle in JSON - const specialNodeNames = [ - 'Node with spaces', - 'Node-with-dashes', - 'Node_with_underscores' - ]; - - for (const specialName of specialNodeNames) { - const workflow = { - nodes: [ - { - id: '1', - name: 'Source', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - }, - { - id: '2', - name: 'Success', - type: 'n8n-nodes-base.set', - position: [200, 0], - parameters: {} - }, - { - id: '3', - name: specialName, - type: 'n8n-nodes-base.respondToWebhook', - position: [200, 100], - parameters: {} - } - ], - connections: { - 'Source': { - main: [ - [ - { node: 'Success', type: 'main', index: 0 }, - { node: specialName, type: 'main', index: 0 } - ] - ] - } - } - }; - - const result = await validator.validateWorkflow(workflow as any); - - const errorConfigError = result.errors.find(e => - e.message.includes('Incorrect error output configuration') - ); - - expect(errorConfigError).toBeDefined(); - - // Verify the error message contains the special node name - expect(errorConfigError!.message).toContain(specialName); - - // Verify JSON structure is present (but don't parse due to comments) - expect(errorConfigError!.message).toContain('INCORRECT (current):'); - expect(errorConfigError!.message).toContain('CORRECT (should be):'); - expect(errorConfigError!.message).toContain('main[0]'); - expect(errorConfigError!.message).toContain('main[1]'); - } - }); - }); - - describe('Repository Interaction Patterns', () => { - it('should call repository getNode with correct parameters', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP Node', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - }, - { - id: '2', - name: 'Set Node', - type: 'n8n-nodes-base.set', - position: [200, 0], - parameters: {} - } - ], - connections: { - 'HTTP Node': { - main: [ - [ - { node: 'Set Node', type: 'main', index: 0 } - ] - ] - } - } - }; - - await validator.validateWorkflow(workflow as any); - - // Should have called getNode for each node type (normalized to short form) - // Called during node validation + output/input index bounds checking - expect(mockGetNode).toHaveBeenCalledWith('nodes-base.httpRequest'); - expect(mockGetNode).toHaveBeenCalledWith('nodes-base.set'); - expect(mockGetNode.mock.calls.length).toBeGreaterThanOrEqual(2); - }); - - it('should handle repository errors gracefully', async () => { - // Mock repository to throw error - mockGetNode.mockImplementation(() => { - throw new Error('Database connection failed'); - }); - - const workflow = { - nodes: [ - { - id: '1', - name: 'Test Node', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - } - ], - connections: {} - }; - - // Should not throw error - const result = await validator.validateWorkflow(workflow as any); - - // Should still return a valid result - expect(result).toHaveProperty('valid'); - expect(Array.isArray(result.errors)).toBe(true); - expect(Array.isArray(result.warnings)).toBe(true); - }); - - it('should optimize repository calls for duplicate node types', async () => { - const workflow = { - nodes: [ - { - id: '1', - name: 'HTTP 1', - type: 'n8n-nodes-base.httpRequest', - position: [0, 0], - parameters: {} - }, - { - id: '2', - name: 'HTTP 2', - type: 'n8n-nodes-base.httpRequest', - position: [200, 0], - parameters: {} - }, - { - id: '3', - name: 'HTTP 3', - type: 'n8n-nodes-base.httpRequest', - position: [400, 0], - parameters: {} - } - ], - connections: {} - }; - - await validator.validateWorkflow(workflow as any); - - // Should call getNode for the same type multiple times (current implementation) - // Note: This test documents current behavior. Could be optimized in the future. - const httpRequestCalls = mockGetNode.mock.calls.filter( - call => call[0] === 'nodes-base.httpRequest' - ); - expect(httpRequestCalls.length).toBeGreaterThan(0); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-performance.test.ts b/tests/unit/services/workflow-validator-performance.test.ts deleted file mode 100644 index 4c64c5f..0000000 --- a/tests/unit/services/workflow-validator-performance.test.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -vi.mock('@/utils/logger'); - -describe('WorkflowValidator - Performance Tests', () => { - let validator: WorkflowValidator; - let mockNodeRepository: any; - - beforeEach(() => { - vi.clearAllMocks(); - - // Create mock repository with performance optimizations - mockNodeRepository = { - getNode: vi.fn((type: string) => { - // Return mock node info for any node type to avoid database calls - return { - node_type: type, - display_name: 'Mock Node', - isVersioned: true, - version: 1 - }; - }) - }; - - validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); - }); - - describe('Large Workflow Performance', () => { - it('should validate large workflows with many error paths efficiently', async () => { - // Generate a large workflow with 500 nodes - const nodeCount = 500; - const nodes = []; - const connections: any = {}; - - // Create nodes with various error handling patterns - for (let i = 1; i <= nodeCount; i++) { - nodes.push({ - id: i.toString(), - name: `Node${i}`, - type: i % 5 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set', - typeVersion: 1, - position: [i * 10, (i % 10) * 100], - parameters: {}, - ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {}) - }); - } - - // Create connections with multiple error handling scenarios - for (let i = 1; i < nodeCount; i++) { - const hasErrorHandling = i % 3 === 0; - const hasMultipleConnections = i % 7 === 0; - - if (hasErrorHandling && hasMultipleConnections) { - // Mix correct and incorrect error handling patterns - const isIncorrect = i % 14 === 0; - - if (isIncorrect) { - // Incorrect: error handlers mixed with success nodes in main[0] - connections[`Node${i}`] = { - main: [ - [ - { node: `Node${i + 1}`, type: 'main', index: 0 }, - { node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong! - ] - ] - }; - } else { - // Correct: separate success and error outputs - connections[`Node${i}`] = { - main: [ - [ - { node: `Node${i + 1}`, type: 'main', index: 0 } - ], - [ - { node: `Error Handler ${i}`, type: 'main', index: 0 } - ] - ] - }; - } - - // Add error handler node - nodes.push({ - id: `error-${i}`, - name: `Error Handler ${i}`, - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1, - position: [(i + nodeCount) * 10, 500], - parameters: {} - }); - } else { - // Simple connection - connections[`Node${i}`] = { - main: [ - [ - { node: `Node${i + 1}`, type: 'main', index: 0 } - ] - ] - }; - } - } - - const workflow = { nodes, connections }; - - const startTime = performance.now(); - const result = await validator.validateWorkflow(workflow as any); - const endTime = performance.now(); - - const executionTime = endTime - startTime; - - // Validation should complete within reasonable time - expect(executionTime).toBeLessThan(10000); // Less than 10 seconds - - // Should still catch validation errors - expect(Array.isArray(result.errors)).toBe(true); - expect(Array.isArray(result.warnings)).toBe(true); - - // Should detect incorrect error configurations - const incorrectConfigErrors = result.errors.filter(e => - e.message.includes('Incorrect error output configuration') - ); - expect(incorrectConfigErrors.length).toBeGreaterThan(0); - - console.log(`Validated ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`); - console.log(`Found ${result.errors.length} errors and ${result.warnings.length} warnings`); - }); - - it('should handle deeply nested error handling chains efficiently', async () => { - // Create a chain of error handlers, each with their own error handling - const chainLength = 100; - const nodes = []; - const connections: any = {}; - - for (let i = 1; i <= chainLength; i++) { - // Main processing node - nodes.push({ - id: `main-${i}`, - name: `Main ${i}`, - type: 'n8n-nodes-base.httpRequest', - typeVersion: 1, - position: [i * 150, 100], - parameters: {}, - onError: 'continueErrorOutput' - }); - - // Error handler node - nodes.push({ - id: `error-${i}`, - name: `Error Handler ${i}`, - type: 'n8n-nodes-base.httpRequest', - typeVersion: 1, - position: [i * 150, 300], - parameters: {}, - onError: 'continueErrorOutput' - }); - - // Fallback error node - nodes.push({ - id: `fallback-${i}`, - name: `Fallback ${i}`, - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [i * 150, 500], - parameters: {} - }); - - // Connections - connections[`Main ${i}`] = { - main: [ - // Success path - i < chainLength ? [{ node: `Main ${i + 1}`, type: 'main', index: 0 }] : [], - // Error path - [{ node: `Error Handler ${i}`, type: 'main', index: 0 }] - ] - }; - - connections[`Error Handler ${i}`] = { - main: [ - // Success path (continue to next error handler or end) - [], - // Error path (go to fallback) - [{ node: `Fallback ${i}`, type: 'main', index: 0 }] - ] - }; - } - - const workflow = { nodes, connections }; - - const startTime = performance.now(); - const result = await validator.validateWorkflow(workflow as any); - const endTime = performance.now(); - - const executionTime = endTime - startTime; - - // Should complete quickly even with complex nested error handling - expect(executionTime).toBeLessThan(5000); // Less than 5 seconds - - // Should not have errors about incorrect configuration (this is correct) - const incorrectConfigErrors = result.errors.filter(e => - e.message.includes('Incorrect error output configuration') - ); - expect(incorrectConfigErrors.length).toBe(0); - - console.log(`Validated ${nodes.length} nodes with nested error handling in ${executionTime.toFixed(2)}ms`); - }); - - it('should efficiently validate workflows with many parallel error paths', async () => { - // Create a workflow with one source node that fans out to many parallel paths, - // each with their own error handling - const parallelPathCount = 200; - const nodes = [ - { - id: 'source', - name: 'Source', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [0, 0], - parameters: {} - } - ]; - const connections: any = { - 'Source': { - main: [[]] - } - }; - - // Create parallel paths - for (let i = 1; i <= parallelPathCount; i++) { - // Processing node - nodes.push({ - id: `process-${i}`, - name: `Process ${i}`, - type: 'n8n-nodes-base.httpRequest', - typeVersion: 1, - position: [200, i * 20], - parameters: {}, - onError: 'continueErrorOutput' - } as any); - - // Success handler - nodes.push({ - id: `success-${i}`, - name: `Success ${i}`, - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [400, i * 20], - parameters: {} - }); - - // Error handler - nodes.push({ - id: `error-${i}`, - name: `Error Handler ${i}`, - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1, - position: [400, i * 20 + 10], - parameters: {} - }); - - // Connect source to processing node - connections['Source'].main[0].push({ - node: `Process ${i}`, - type: 'main', - index: 0 - }); - - // Connect processing node to success and error handlers - connections[`Process ${i}`] = { - main: [ - [{ node: `Success ${i}`, type: 'main', index: 0 }], - [{ node: `Error Handler ${i}`, type: 'main', index: 0 }] - ] - }; - } - - const workflow = { nodes, connections }; - - const startTime = performance.now(); - const result = await validator.validateWorkflow(workflow as any); - const endTime = performance.now(); - - const executionTime = endTime - startTime; - - // Should validate efficiently despite many parallel paths - expect(executionTime).toBeLessThan(8000); // Less than 8 seconds - - // Should not have errors about incorrect configuration - const incorrectConfigErrors = result.errors.filter(e => - e.message.includes('Incorrect error output configuration') - ); - expect(incorrectConfigErrors.length).toBe(0); - - console.log(`Validated ${nodes.length} nodes with ${parallelPathCount} parallel error paths in ${executionTime.toFixed(2)}ms`); - }); - - it('should handle worst-case scenario with many incorrect configurations efficiently', async () => { - // Create a workflow where many nodes have the incorrect error configuration - // This tests the performance of the error detection algorithm - const nodeCount = 300; - const nodes = []; - const connections: any = {}; - - for (let i = 1; i <= nodeCount; i++) { - // Main node - nodes.push({ - id: `main-${i}`, - name: `Main ${i}`, - type: 'n8n-nodes-base.httpRequest', - typeVersion: 1, - position: [i * 20, 100], - parameters: {} - }); - - // Success handler - nodes.push({ - id: `success-${i}`, - name: `Success ${i}`, - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [i * 20, 200], - parameters: {} - }); - - // Error handler (with error-indicating name) - nodes.push({ - id: `error-${i}`, - name: `Error Handler ${i}`, - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1, - position: [i * 20, 300], - parameters: {} - }); - - // INCORRECT configuration: both success and error handlers in main[0] - connections[`Main ${i}`] = { - main: [ - [ - { node: `Success ${i}`, type: 'main', index: 0 }, - { node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong! - ] - ] - }; - } - - const workflow = { nodes, connections }; - - const startTime = performance.now(); - const result = await validator.validateWorkflow(workflow as any); - const endTime = performance.now(); - - const executionTime = endTime - startTime; - - // Should complete within reasonable time even when generating many errors - expect(executionTime).toBeLessThan(15000); // Less than 15 seconds - - // Should detect ALL incorrect configurations - const incorrectConfigErrors = result.errors.filter(e => - e.message.includes('Incorrect error output configuration') - ); - expect(incorrectConfigErrors.length).toBe(nodeCount); // One error per node - - console.log(`Detected ${incorrectConfigErrors.length} incorrect configurations in ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`); - }); - }); - - describe('Memory Usage and Optimization', () => { - it('should not leak memory during large workflow validation', async () => { - // Get initial memory usage - const initialMemory = process.memoryUsage().heapUsed; - - // Validate multiple large workflows - for (let run = 0; run < 5; run++) { - const nodeCount = 200; - const nodes = []; - const connections: any = {}; - - for (let i = 1; i <= nodeCount; i++) { - nodes.push({ - id: i.toString(), - name: `Node${i}`, - type: 'n8n-nodes-base.httpRequest', - typeVersion: 1, - position: [i * 10, 100], - parameters: {}, - onError: 'continueErrorOutput' - }); - - if (i > 1) { - connections[`Node${i - 1}`] = { - main: [ - [{ node: `Node${i}`, type: 'main', index: 0 }], - [{ node: `Error${i}`, type: 'main', index: 0 }] - ] - }; - - nodes.push({ - id: `error-${i}`, - name: `Error${i}`, - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [i * 10, 200], - parameters: {} - }); - } - } - - const workflow = { nodes, connections }; - await validator.validateWorkflow(workflow as any); - - // Force garbage collection if available - if (global.gc) { - global.gc(); - } - } - - const finalMemory = process.memoryUsage().heapUsed; - const memoryIncrease = finalMemory - initialMemory; - const memoryIncreaseMB = memoryIncrease / (1024 * 1024); - - // Memory increase should be reasonable (less than 50MB) - expect(memoryIncreaseMB).toBeLessThan(50); - - console.log(`Memory increase after 5 large workflow validations: ${memoryIncreaseMB.toFixed(2)}MB`); - }); - - it('should handle concurrent validation requests efficiently', async () => { - // Create multiple validation requests that run concurrently - const concurrentRequests = 10; - const workflows = []; - - // Prepare workflows - for (let r = 0; r < concurrentRequests; r++) { - const nodeCount = 50; - const nodes = []; - const connections: any = {}; - - for (let i = 1; i <= nodeCount; i++) { - nodes.push({ - id: `${r}-${i}`, - name: `R${r}Node${i}`, - type: i % 2 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set', - typeVersion: 1, - position: [i * 20, r * 100], - parameters: {}, - ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {}) - }); - - if (i > 1) { - const hasError = i % 3 === 0; - const isIncorrect = i % 6 === 0; - - if (hasError && isIncorrect) { - // Incorrect configuration - connections[`R${r}Node${i - 1}`] = { - main: [ - [ - { node: `R${r}Node${i}`, type: 'main', index: 0 }, - { node: `R${r}Error${i}`, type: 'main', index: 0 } // Wrong! - ] - ] - }; - - nodes.push({ - id: `${r}-error-${i}`, - name: `R${r}Error${i}`, - type: 'n8n-nodes-base.respondToWebhook', - typeVersion: 1, - position: [i * 20, r * 100 + 50], - parameters: {} - }); - } else if (hasError) { - // Correct configuration - connections[`R${r}Node${i - 1}`] = { - main: [ - [{ node: `R${r}Node${i}`, type: 'main', index: 0 }], - [{ node: `R${r}Error${i}`, type: 'main', index: 0 }] - ] - }; - - nodes.push({ - id: `${r}-error-${i}`, - name: `R${r}Error${i}`, - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [i * 20, r * 100 + 50], - parameters: {} - }); - } else { - // Normal connection - connections[`R${r}Node${i - 1}`] = { - main: [ - [{ node: `R${r}Node${i}`, type: 'main', index: 0 }] - ] - }; - } - } - } - - workflows.push({ nodes, connections }); - } - - // Run concurrent validations - const startTime = performance.now(); - const results = await Promise.all( - workflows.map(workflow => validator.validateWorkflow(workflow as any)) - ); - const endTime = performance.now(); - - const totalTime = endTime - startTime; - - // All validations should complete - expect(results).toHaveLength(concurrentRequests); - - // Each result should be valid - results.forEach(result => { - expect(Array.isArray(result.errors)).toBe(true); - expect(Array.isArray(result.warnings)).toBe(true); - }); - - // Concurrent execution should be efficient - expect(totalTime).toBeLessThan(20000); // Less than 20 seconds total - - console.log(`Completed ${concurrentRequests} concurrent validations in ${totalTime.toFixed(2)}ms`); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-tool-variants.test.ts b/tests/unit/services/workflow-validator-tool-variants.test.ts deleted file mode 100644 index a7a14d6..0000000 --- a/tests/unit/services/workflow-validator-tool-variants.test.ts +++ /dev/null @@ -1,892 +0,0 @@ -/** - * Tests for WorkflowValidator - Tool Variant Validation - * - * Tests the validateAIToolSource() method which ensures that base nodes - * with ai_tool connections use the correct Tool variant node type. - * - * Coverage: - * - Langchain tool nodes pass validation - * - Tool variant nodes pass validation - * - Base nodes with Tool variants fail with WRONG_NODE_TYPE_FOR_AI_TOOL - * - Error includes fix suggestion with tool-variant-correction type - * - Unknown nodes don't cause errors - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { NodeRepository } from '@/database/node-repository'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -// Mock dependencies -vi.mock('@/database/node-repository'); -vi.mock('@/services/enhanced-config-validator'); -vi.mock('@/utils/logger'); - -describe('WorkflowValidator - Tool Variant Validation', () => { - let validator: WorkflowValidator; - let mockRepository: NodeRepository; - let mockValidator: typeof EnhancedConfigValidator; - - beforeEach(() => { - vi.clearAllMocks(); - - // Create mock repository - mockRepository = { - getNode: vi.fn((nodeType: string) => { - // Mock base node with Tool variant available - if (nodeType === 'nodes-base.supabase') { - return { - nodeType: 'nodes-base.supabase', - displayName: 'Supabase', - isAITool: true, - hasToolVariant: true, - isToolVariant: false, - isTrigger: false, - properties: [] - }; - } - - // Mock Tool variant node - if (nodeType === 'nodes-base.supabaseTool') { - return { - nodeType: 'nodes-base.supabaseTool', - displayName: 'Supabase Tool', - isAITool: true, - hasToolVariant: false, - isToolVariant: true, - toolVariantOf: 'nodes-base.supabase', - isTrigger: false, - properties: [] - }; - } - - // Mock langchain node (Calculator tool) - if (nodeType === 'nodes-langchain.toolCalculator') { - return { - nodeType: 'nodes-langchain.toolCalculator', - displayName: 'Calculator', - isAITool: true, - hasToolVariant: false, - isToolVariant: false, - isTrigger: false, - properties: [] - }; - } - - // Mock HTTP Request Tool node - if (nodeType === 'nodes-langchain.toolHttpRequest') { - return { - nodeType: 'nodes-langchain.toolHttpRequest', - displayName: 'HTTP Request Tool', - isAITool: true, - hasToolVariant: false, - isToolVariant: false, - isTrigger: false, - properties: [] - }; - } - - // Mock base node without Tool variant - if (nodeType === 'nodes-base.httpRequest') { - return { - nodeType: 'nodes-base.httpRequest', - displayName: 'HTTP Request', - isAITool: false, - hasToolVariant: false, - isToolVariant: false, - isTrigger: false, - properties: [] - }; - } - - return null; // Unknown node - }) - } as any; - - mockValidator = EnhancedConfigValidator; - - validator = new WorkflowValidator(mockRepository, mockValidator); - }); - - describe('validateAIToolSource - Langchain tool nodes', () => { - it('should pass validation for Calculator tool node', async () => { - const workflow = { - nodes: [ - { - id: 'calculator-1', - name: 'Calculator', - type: 'n8n-nodes-langchain.toolCalculator', - typeVersion: 1.2, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - Calculator: { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // Should not have errors about wrong node type for AI tool - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(0); - }); - - it('should pass validation for HTTP Request Tool node', async () => { - const workflow = { - nodes: [ - { - id: 'http-tool-1', - name: 'HTTP Request Tool', - type: '@n8n/n8n-nodes-langchain.toolHttpRequest', - typeVersion: 1.2, - position: [250, 300] as [number, number], - parameters: { - url: 'https://api.example.com', - toolDescription: 'Fetch data from API' - } - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'HTTP Request Tool': { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(0); - }); - }); - - describe('validateAIToolSource - Tool variant nodes', () => { - it('should pass validation for Tool variant node (supabaseTool)', async () => { - const workflow = { - nodes: [ - { - id: 'supabase-tool-1', - name: 'Supabase Tool', - type: 'n8n-nodes-base.supabaseTool', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: { - toolDescription: 'Query Supabase database' - } - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'Supabase Tool': { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(0); - }); - - it('should verify Tool variant is marked correctly in database', async () => { - const workflow = { - nodes: [ - { - id: 'supabase-tool-1', - name: 'Supabase Tool', - type: 'n8n-nodes-base.supabaseTool', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'Supabase Tool': { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - await validator.validateWorkflow(workflow); - - // Verify repository was called to check if it's a Tool variant - expect(mockRepository.getNode).toHaveBeenCalledWith('nodes-base.supabaseTool'); - }); - }); - - describe('validateAIToolSource - Base nodes with Tool variants', () => { - it('should fail when base node is used instead of Tool variant', async () => { - const workflow = { - nodes: [ - { - id: 'supabase-1', - name: 'Supabase', - type: 'n8n-nodes-base.supabase', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - Supabase: { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have error with WRONG_NODE_TYPE_FOR_AI_TOOL code - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(1); - }); - - it('should include fix suggestion in error', async () => { - const workflow = { - nodes: [ - { - id: 'supabase-1', - name: 'Supabase', - type: 'n8n-nodes-base.supabase', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - Supabase: { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - const toolVariantError = result.errors.find(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ) as any; - - expect(toolVariantError).toBeDefined(); - expect(toolVariantError.fix).toBeDefined(); - expect(toolVariantError.fix.type).toBe('tool-variant-correction'); - expect(toolVariantError.fix.currentType).toBe('n8n-nodes-base.supabase'); - expect(toolVariantError.fix.suggestedType).toBe('n8n-nodes-base.supabaseTool'); - expect(toolVariantError.fix.description).toContain('n8n-nodes-base.supabase'); - expect(toolVariantError.fix.description).toContain('n8n-nodes-base.supabaseTool'); - }); - - it('should provide clear error message', async () => { - const workflow = { - nodes: [ - { - id: 'supabase-1', - name: 'Supabase', - type: 'n8n-nodes-base.supabase', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - Supabase: { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - const toolVariantError = result.errors.find(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - - expect(toolVariantError).toBeDefined(); - expect(toolVariantError!.message).toContain('cannot output ai_tool connections'); - expect(toolVariantError!.message).toContain('Tool variant'); - expect(toolVariantError!.message).toContain('n8n-nodes-base.supabaseTool'); - }); - - it('should handle multiple base nodes incorrectly used as tools', async () => { - mockRepository.getNode = vi.fn((nodeType: string) => { - if (nodeType === 'nodes-base.postgres') { - return { - nodeType: 'nodes-base.postgres', - displayName: 'Postgres', - isAITool: true, - hasToolVariant: true, - isToolVariant: false, - isTrigger: false, - properties: [] - }; - } - if (nodeType === 'nodes-base.supabase') { - return { - nodeType: 'nodes-base.supabase', - displayName: 'Supabase', - isAITool: true, - hasToolVariant: true, - isToolVariant: false, - isTrigger: false, - properties: [] - }; - } - return null; - }) as any; - - const workflow = { - nodes: [ - { - id: 'postgres-1', - name: 'Postgres', - type: 'n8n-nodes-base.postgres', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'supabase-1', - name: 'Supabase', - type: 'n8n-nodes-base.supabase', - typeVersion: 1, - position: [250, 400] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - Postgres: { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - }, - Supabase: { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(2); - }); - }); - - describe('validateAIToolSource - Unknown nodes', () => { - it('should not error for unknown node types', async () => { - const workflow = { - nodes: [ - { - id: 'unknown-1', - name: 'Unknown Tool', - type: 'custom-package.unknownTool', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'Unknown Tool': { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // Unknown nodes should not cause tool variant errors - // Let other validation handle unknown node types - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(0); - - // But there might be an "Unknown node type" error from different validation - const unknownNodeErrors = result.errors.filter(e => - e.message && e.message.includes('Unknown node type') - ); - expect(unknownNodeErrors.length).toBeGreaterThan(0); - }); - - it('should not error for community nodes', async () => { - const workflow = { - nodes: [ - { - id: 'community-1', - name: 'Community Tool', - type: 'community-package.customTool', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'Community Tool': { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // Community nodes should not cause tool variant errors - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(0); - }); - }); - - describe('validateAIToolSource - Edge cases', () => { - it('should not error for base nodes without ai_tool connections', async () => { - const workflow = { - nodes: [ - { - id: 'supabase-1', - name: 'Supabase', - type: 'n8n-nodes-base.supabase', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'set-1', - name: 'Set', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - Supabase: { - main: [[{ node: 'Set', type: 'main', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // No ai_tool connections, so no tool variant validation errors - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(0); - }); - - it('should not error when base node without Tool variant uses ai_tool', async () => { - const workflow = { - nodes: [ - { - id: 'http-1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'HTTP Request': { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // httpRequest has no Tool variant, so this should produce a different error - const toolVariantErrors = result.errors.filter(e => - e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL' - ); - expect(toolVariantErrors).toHaveLength(0); - - // Should have INVALID_AI_TOOL_SOURCE error instead - const invalidToolErrors = result.errors.filter(e => - e.code === 'INVALID_AI_TOOL_SOURCE' - ); - expect(invalidToolErrors.length).toBeGreaterThan(0); - }); - }); - - describe('validateAllNodes - Inferred Tool Variants (Issue #522)', () => { - /** - * Tests for dynamic AI Tool nodes that are created at runtime by n8n - * when ANY node is used in an AI Agent's tool slot. - * - * These nodes (e.g., googleDriveTool, googleSheetsTool) don't exist in npm packages - * but are valid when the base node exists. - */ - - beforeEach(() => { - // Update mock repository to include Google nodes - mockRepository.getNode = vi.fn((nodeType: string) => { - // Base node with Tool variant - if (nodeType === 'nodes-base.supabase') { - return { - nodeType: 'nodes-base.supabase', - displayName: 'Supabase', - isAITool: true, - hasToolVariant: true, - isToolVariant: false, - isTrigger: false, - properties: [] - }; - } - - // Tool variant in database - if (nodeType === 'nodes-base.supabaseTool') { - return { - nodeType: 'nodes-base.supabaseTool', - displayName: 'Supabase Tool', - isAITool: true, - hasToolVariant: false, - isToolVariant: true, - toolVariantOf: 'nodes-base.supabase', - isTrigger: false, - properties: [] - }; - } - - // Google Drive base node (exists, but no Tool variant in DB) - if (nodeType === 'nodes-base.googleDrive') { - return { - nodeType: 'nodes-base.googleDrive', - displayName: 'Google Drive', - isAITool: false, // Not marked as AI tool in npm package - hasToolVariant: false, // No Tool variant in database - isToolVariant: false, - isTrigger: false, - properties: [], - category: 'files' - }; - } - - // Google Sheets base node (exists, but no Tool variant in DB) - if (nodeType === 'nodes-base.googleSheets') { - return { - nodeType: 'nodes-base.googleSheets', - displayName: 'Google Sheets', - isAITool: false, - hasToolVariant: false, - isToolVariant: false, - isTrigger: false, - properties: [], - category: 'productivity' - }; - } - - // AI Agent node - if (nodeType === 'nodes-langchain.agent') { - return { - nodeType: 'nodes-langchain.agent', - displayName: 'AI Agent', - isAITool: false, - hasToolVariant: false, - isToolVariant: false, - isTrigger: false, - properties: [] - }; - } - - return null; // Unknown node - }) as any; - }); - - it('should pass validation for googleDriveTool when googleDrive exists', async () => { - const workflow = { - nodes: [ - { - id: 'drive-tool-1', - name: 'Google Drive Tool', - type: 'n8n-nodes-base.googleDriveTool', - typeVersion: 3, - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should NOT have "Unknown node type" error - const unknownErrors = result.errors.filter(e => - e.message && e.message.includes('Unknown node type') - ); - expect(unknownErrors).toHaveLength(0); - - // Should have INFERRED_TOOL_VARIANT warning - const inferredWarnings = result.warnings.filter(e => - (e as any).code === 'INFERRED_TOOL_VARIANT' - ); - expect(inferredWarnings).toHaveLength(1); - expect(inferredWarnings[0].message).toContain('googleDriveTool'); - expect(inferredWarnings[0].message).toContain('Google Drive'); - }); - - it('should pass validation for googleSheetsTool when googleSheets exists', async () => { - const workflow = { - nodes: [ - { - id: 'sheets-tool-1', - name: 'Google Sheets Tool', - type: 'n8n-nodes-base.googleSheetsTool', - typeVersion: 4, - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should NOT have "Unknown node type" error - const unknownErrors = result.errors.filter(e => - e.message && e.message.includes('Unknown node type') - ); - expect(unknownErrors).toHaveLength(0); - - // Should have INFERRED_TOOL_VARIANT warning - const inferredWarnings = result.warnings.filter(e => - (e as any).code === 'INFERRED_TOOL_VARIANT' - ); - expect(inferredWarnings).toHaveLength(1); - expect(inferredWarnings[0].message).toContain('googleSheetsTool'); - expect(inferredWarnings[0].message).toContain('Google Sheets'); - }); - - it('should report error for unknownNodeTool when base node does not exist', async () => { - const workflow = { - nodes: [ - { - id: 'unknown-tool-1', - name: 'Unknown Tool', - type: 'n8n-nodes-base.nonExistentNodeTool', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should have "Unknown node type" error - const unknownErrors = result.errors.filter(e => - e.message && e.message.includes('Unknown node type') - ); - expect(unknownErrors).toHaveLength(1); - - // Should NOT have INFERRED_TOOL_VARIANT warning - const inferredWarnings = result.warnings.filter(e => - (e as any).code === 'INFERRED_TOOL_VARIANT' - ); - expect(inferredWarnings).toHaveLength(0); - }); - - it('should handle multiple inferred tool variants in same workflow', async () => { - const workflow = { - nodes: [ - { - id: 'drive-tool-1', - name: 'Google Drive Tool', - type: 'n8n-nodes-base.googleDriveTool', - typeVersion: 3, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: 'sheets-tool-1', - name: 'Google Sheets Tool', - type: 'n8n-nodes-base.googleSheetsTool', - typeVersion: 4, - position: [250, 400] as [number, number], - parameters: {} - }, - { - id: 'agent-1', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 1.7, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'Google Drive Tool': { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - }, - 'Google Sheets Tool': { - ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]] - } - } - }; - - const result = await validator.validateWorkflow(workflow); - - // Should NOT have "Unknown node type" errors - const unknownErrors = result.errors.filter(e => - e.message && e.message.includes('Unknown node type') - ); - expect(unknownErrors).toHaveLength(0); - - // Should have 2 INFERRED_TOOL_VARIANT warnings - const inferredWarnings = result.warnings.filter(e => - (e as any).code === 'INFERRED_TOOL_VARIANT' - ); - expect(inferredWarnings).toHaveLength(2); - }); - - it('should prefer database record over inference for supabaseTool', async () => { - const workflow = { - nodes: [ - { - id: 'supabase-tool-1', - name: 'Supabase Tool', - type: 'n8n-nodes-base.supabaseTool', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - // Should NOT have "Unknown node type" error - const unknownErrors = result.errors.filter(e => - e.message && e.message.includes('Unknown node type') - ); - expect(unknownErrors).toHaveLength(0); - - // Should NOT have INFERRED_TOOL_VARIANT warning (it's in database) - const inferredWarnings = result.warnings.filter(e => - (e as any).code === 'INFERRED_TOOL_VARIANT' - ); - expect(inferredWarnings).toHaveLength(0); - }); - - it('should include helpful message in warning', async () => { - const workflow = { - nodes: [ - { - id: 'drive-tool-1', - name: 'Google Drive Tool', - type: 'n8n-nodes-base.googleDriveTool', - typeVersion: 3, - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - const result = await validator.validateWorkflow(workflow); - - const inferredWarning = result.warnings.find(e => - (e as any).code === 'INFERRED_TOOL_VARIANT' - ); - - expect(inferredWarning).toBeDefined(); - expect(inferredWarning!.message).toContain('inferred as a dynamic AI Tool variant'); - expect(inferredWarning!.message).toContain('nodes-base.googleDrive'); - expect(inferredWarning!.message).toContain('Google Drive'); - expect(inferredWarning!.message).toContain('AI Agent'); - }); - }); -}); diff --git a/tests/unit/services/workflow-validator-with-mocks.test.ts b/tests/unit/services/workflow-validator-with-mocks.test.ts deleted file mode 100644 index eae6bb1..0000000 --- a/tests/unit/services/workflow-validator-with-mocks.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { WorkflowValidator } from '@/services/workflow-validator'; -import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; - -// Mock logger to prevent console output -vi.mock('@/utils/logger', () => ({ - Logger: vi.fn().mockImplementation(() => ({ - error: vi.fn(), - warn: vi.fn(), - info: vi.fn() - })) -})); - -describe('WorkflowValidator - Simple Unit Tests', () => { - let validator: WorkflowValidator; - - // Create a simple mock repository - const createMockRepository = (nodeData: Record) => ({ - getNode: vi.fn((type: string) => nodeData[type] || null), - findSimilarNodes: vi.fn().mockReturnValue([]) - }); - - // Create a simple mock validator class - const createMockValidatorClass = (validationResult: any) => ({ - validateWithMode: vi.fn().mockReturnValue(validationResult) - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('Basic validation scenarios', () => { - it('should pass validation for a webhook workflow with single node', async () => { - // Arrange - const nodeData = { - 'n8n-nodes-base.webhook': { - type: 'nodes-base.webhook', - displayName: 'Webhook', - name: 'webhook', - version: 1, - isVersioned: true, - properties: [] - }, - 'nodes-base.webhook': { - type: 'nodes-base.webhook', - displayName: 'Webhook', - name: 'webhook', - version: 1, - isVersioned: true, - properties: [] - } - }; - - const mockRepository = createMockRepository(nodeData); - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - const workflow = { - name: 'Webhook Workflow', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - // Act - const result = await validator.validateWorkflow(workflow as any); - - // Assert - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - // Single webhook node should just have a warning about no connections - expect(result.warnings.some(w => w.message.includes('no connections'))).toBe(true); - }); - - it('should fail validation for unknown node types', async () => { - // Arrange - const mockRepository = createMockRepository({}); // Empty node data - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - const workflow = { - name: 'Test Workflow', - nodes: [ - { - id: '1', - name: 'Unknown', - type: 'n8n-nodes-base.unknownNode', - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - // Act - const result = await validator.validateWorkflow(workflow as any); - - // Assert - expect(result.valid).toBe(false); - // Check for either the error message or valid being false - const hasUnknownNodeError = result.errors.some(e => - e.message && (e.message.includes('Unknown node type') || e.message.includes('unknown-node-type')) - ); - expect(result.errors.length > 0 || hasUnknownNodeError).toBe(true); - }); - - it('should detect duplicate node names', async () => { - // Arrange - const mockRepository = createMockRepository({}); - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - const workflow = { - name: 'Duplicate Names', - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: '2', - name: 'HTTP Request', // Duplicate name - type: 'n8n-nodes-base.httpRequest', - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - // Act - const result = await validator.validateWorkflow(workflow as any); - - // Assert - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('Duplicate node name'))).toBe(true); - }); - - it('should validate connections properly', async () => { - // Arrange - const nodeData = { - 'n8n-nodes-base.manualTrigger': { - type: 'nodes-base.manualTrigger', - displayName: 'Manual Trigger', - isVersioned: false, - properties: [] - }, - 'nodes-base.manualTrigger': { - type: 'nodes-base.manualTrigger', - displayName: 'Manual Trigger', - isVersioned: false, - properties: [] - }, - 'n8n-nodes-base.set': { - type: 'nodes-base.set', - displayName: 'Set', - version: 2, - isVersioned: true, - properties: [] - }, - 'nodes-base.set': { - type: 'nodes-base.set', - displayName: 'Set', - version: 2, - isVersioned: true, - properties: [] - } - }; - - const mockRepository = createMockRepository(nodeData); - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - const workflow = { - name: 'Connected Workflow', - nodes: [ - { - id: '1', - name: 'Manual Trigger', - type: 'n8n-nodes-base.manualTrigger', - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'Manual Trigger': { - main: [[{ node: 'Set', type: 'main', index: 0 }]] - } - } - }; - - // Act - const result = await validator.validateWorkflow(workflow as any); - - // Assert - expect(result.valid).toBe(true); - expect(result.statistics.validConnections).toBe(1); - expect(result.statistics.invalidConnections).toBe(0); - }); - - it('should detect workflow cycles', async () => { - // Arrange - const nodeData = { - 'n8n-nodes-base.set': { - type: 'nodes-base.set', - displayName: 'Set', - isVersioned: true, - version: 2, - properties: [] - }, - 'nodes-base.set': { - type: 'nodes-base.set', - displayName: 'Set', - isVersioned: true, - version: 2, - properties: [] - } - }; - - const mockRepository = createMockRepository(nodeData); - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - const workflow = { - name: 'Cyclic Workflow', - nodes: [ - { - id: '1', - name: 'Node A', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: '2', - name: 'Node B', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: { - 'Node A': { - main: [[{ node: 'Node B', type: 'main', index: 0 }]] - }, - 'Node B': { - main: [[{ node: 'Node A', type: 'main', index: 0 }]] // Creates a cycle - } - } - }; - - // Act - const result = await validator.validateWorkflow(workflow as any); - - // Assert - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('cycle'))).toBe(true); - }); - - it('should handle null workflow gracefully', async () => { - // Arrange - const mockRepository = createMockRepository({}); - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - // Act - const result = await validator.validateWorkflow(null as any); - - // Assert - expect(result.valid).toBe(false); - expect(result.errors[0].message).toContain('workflow is null or undefined'); - }); - - it('should require connections for multi-node workflows', async () => { - // Arrange - const nodeData = { - 'n8n-nodes-base.manualTrigger': { - type: 'nodes-base.manualTrigger', - displayName: 'Manual Trigger', - properties: [] - }, - 'nodes-base.manualTrigger': { - type: 'nodes-base.manualTrigger', - displayName: 'Manual Trigger', - properties: [] - }, - 'n8n-nodes-base.set': { - type: 'nodes-base.set', - displayName: 'Set', - version: 2, - isVersioned: true, - properties: [] - }, - 'nodes-base.set': { - type: 'nodes-base.set', - displayName: 'Set', - version: 2, - isVersioned: true, - properties: [] - } - }; - - const mockRepository = createMockRepository(nodeData); - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - const workflow = { - name: 'No Connections', - nodes: [ - { - id: '1', - name: 'Manual Trigger', - type: 'n8n-nodes-base.manualTrigger', - position: [250, 300] as [number, number], - parameters: {} - }, - { - id: '2', - name: 'Set', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [450, 300] as [number, number], - parameters: {} - } - ], - connections: {} // No connections between nodes - }; - - // Act - const result = await validator.validateWorkflow(workflow as any); - - // Assert - expect(result.valid).toBe(false); - expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true); - }); - - it('should validate typeVersion for versioned nodes', async () => { - // Arrange - const nodeData = { - 'n8n-nodes-base.httpRequest': { - type: 'nodes-base.httpRequest', - displayName: 'HTTP Request', - isVersioned: true, - version: 3, // Latest version is 3 - properties: [] - }, - 'nodes-base.httpRequest': { - type: 'nodes-base.httpRequest', - displayName: 'HTTP Request', - isVersioned: true, - version: 3, - properties: [] - } - }; - - const mockRepository = createMockRepository(nodeData); - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - const workflow = { - name: 'Version Test', - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 2, // Outdated version - position: [250, 300] as [number, number], - parameters: {} - } - ], - connections: {} - }; - - // Act - const result = await validator.validateWorkflow(workflow as any); - - // Assert - expect(result.warnings.some(w => w.message.includes('Outdated typeVersion'))).toBe(true); - }); - - it('should normalize and validate nodes-base prefix to find the node', async () => { - // Arrange - Test that full-form types are normalized to short form to find the node - // The repository only has the node under the SHORT normalized key (database format) - const nodeData = { - 'nodes-base.webhook': { // Repository has it under SHORT form (database format) - type: 'nodes-base.webhook', - displayName: 'Webhook', - isVersioned: true, - version: 2, - properties: [] - } - }; - - // Mock repository that simulates the normalization behavior - // After our changes, getNode is called with the already-normalized type (short form) - const mockRepository = { - getNode: vi.fn((type: string) => { - // The validator now normalizes to short form before calling getNode - // So getNode receives 'nodes-base.webhook' - if (type === 'nodes-base.webhook') { - return nodeData['nodes-base.webhook']; - } - return null; - }), - findSimilarNodes: vi.fn().mockReturnValue([]) - }; - - const mockValidatorClass = createMockValidatorClass({ - valid: true, - errors: [], - warnings: [], - suggestions: [] - }); - - validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any); - - const workflow = { - name: 'Valid Alternative Prefix', - nodes: [ - { - id: '1', - name: 'Webhook', - type: 'n8n-nodes-base.webhook', // Using the full-form prefix (will be normalized to short) - position: [250, 300] as [number, number], - parameters: {}, - typeVersion: 2 - } - ], - connections: {} - }; - - // Act - const result = await validator.validateWorkflow(workflow as any); - - // Assert - The node should be found through normalization - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - - // Verify the repository was called (once with original, once with normalized) - expect(mockRepository.getNode).toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator.test.ts b/tests/unit/services/workflow-validator.test.ts index 923b2f0..2bab631 100644 --- a/tests/unit/services/workflow-validator.test.ts +++ b/tests/unit/services/workflow-validator.test.ts @@ -1,574 +1,746 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { WorkflowValidator } from '@/services/workflow-validator'; +import { NodeRepository } from '@/database/node-repository'; +import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; +import { ExpressionValidator } from '@/services/expression-validator'; +import { createWorkflow } from '@tests/utils/builders/workflow.builder'; -// Note: The WorkflowValidator has complex dependencies that are difficult to mock -// with vi.mock() because: -// 1. It expects NodeRepository instance but EnhancedConfigValidator class -// 2. The dependencies are imported at module level before mocks can be applied -// -// For proper unit testing with mocks, see workflow-validator-simple.test.ts -// which uses manual mocking approach. This file tests the validator logic -// without mocks to ensure the implementation works correctly. - +// Mock dependencies +vi.mock('@/database/node-repository'); +vi.mock('@/services/enhanced-config-validator'); +vi.mock('@/services/expression-validator'); vi.mock('@/utils/logger'); describe('WorkflowValidator', () => { let validator: WorkflowValidator; + let mockNodeRepository: NodeRepository; + let mockEnhancedConfigValidator: typeof EnhancedConfigValidator; + + const nodeTypes: Record = { + 'nodes-base.webhook': { type: 'nodes-base.webhook', displayName: 'Webhook', package: 'n8n-nodes-base', isTrigger: true, version: 2, isVersioned: true, outputs: ['main'], properties: [] }, + 'nodes-base.manualTrigger': { type: 'nodes-base.manualTrigger', displayName: 'Manual Trigger', package: 'n8n-nodes-base', isTrigger: true, version: 1, isVersioned: true, outputs: ['main'], properties: [] }, + 'nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', package: 'n8n-nodes-base', version: 3, isVersioned: true, outputs: ['main'], properties: [] }, + 'nodes-base.code': { type: 'nodes-base.code', displayName: 'Code', package: 'n8n-nodes-base', version: 2, isVersioned: true, outputs: ['main'], properties: [] }, + 'nodes-base.httpRequest': { type: 'nodes-base.httpRequest', displayName: 'HTTP Request', package: 'n8n-nodes-base', version: 4, isVersioned: true, outputs: ['main'], properties: [] }, + 'nodes-base.if': { type: 'nodes-base.if', displayName: 'IF', package: 'n8n-nodes-base', version: 2, isVersioned: true, outputs: ['main', 'main'], properties: [] }, + 'nodes-base.filter': { type: 'nodes-base.filter', displayName: 'Filter', package: 'n8n-nodes-base', outputs: ['main', 'main'], properties: [] }, + 'nodes-base.switch': { type: 'nodes-base.switch', displayName: 'Switch', package: 'n8n-nodes-base', outputs: ['main', 'main', 'main', 'main'], properties: [] }, + 'nodes-base.slack': { type: 'nodes-base.slack', displayName: 'Slack', package: 'n8n-nodes-base', version: 2, isVersioned: true, outputs: ['main'], properties: [] }, + 'nodes-base.googleSheets': { type: 'nodes-base.googleSheets', displayName: 'Google Sheets', package: 'n8n-nodes-base', version: 4, isVersioned: true, outputs: ['main'], properties: [] }, + 'nodes-base.merge': { type: 'nodes-base.merge', displayName: 'Merge', package: 'n8n-nodes-base', outputs: ['main'], properties: [] }, + 'nodes-base.postgres': { type: 'nodes-base.postgres', displayName: 'Postgres', package: 'n8n-nodes-base', version: 2, isVersioned: true, outputs: ['main'], properties: [] }, + 'nodes-langchain.agent': { type: 'nodes-langchain.agent', displayName: 'AI Agent', package: '@n8n/n8n-nodes-langchain', version: 1, isVersioned: true, isAITool: true, outputs: ['main'], properties: [] }, + 'nodes-langchain.lmChatGoogleGemini': { type: 'nodes-langchain.lmChatGoogleGemini', displayName: 'Google Gemini Chat Model', package: '@n8n/n8n-nodes-langchain', outputs: ['ai_languageModel'], properties: [] }, + 'nodes-langchain.memoryBufferWindow': { type: 'nodes-langchain.memoryBufferWindow', displayName: 'Window Buffer Memory', package: '@n8n/n8n-nodes-langchain', outputs: ['ai_memory'], properties: [] }, + 'nodes-langchain.embeddingsOpenAi': { type: 'nodes-langchain.embeddingsOpenAi', displayName: 'Embeddings OpenAI', package: '@n8n/n8n-nodes-langchain', outputs: ['ai_embedding'], properties: [] }, + 'nodes-langchain.openAi': { type: 'nodes-langchain.openAi', displayName: 'OpenAI', package: '@n8n/n8n-nodes-langchain', outputs: ['main'], properties: [] }, + 'nodes-langchain.textClassifier': { type: 'nodes-langchain.textClassifier', displayName: 'Text Classifier', package: '@n8n/n8n-nodes-langchain', outputs: ['={{}}'], properties: [] }, + 'nodes-langchain.vectorStoreInMemory': { type: 'nodes-langchain.vectorStoreInMemory', displayName: 'In-Memory Vector Store', package: '@n8n/n8n-nodes-langchain', outputs: ['={{$parameter["mode"] === "retrieve" ? "main" : "ai_vectorStore"}}'], properties: [] }, + 'community.customNode': { type: 'community.customNode', displayName: 'Custom Node', package: 'n8n-nodes-custom', version: 1, isVersioned: false, properties: [], isAITool: false }, + }; beforeEach(() => { vi.clearAllMocks(); - // These tests focus on testing the validation logic without mocking dependencies - // For tests with mocked dependencies, see workflow-validator-simple.test.ts - }); + mockNodeRepository = new NodeRepository({} as any) as any; + mockEnhancedConfigValidator = EnhancedConfigValidator as any; + if (!mockNodeRepository.getAllNodes) { mockNodeRepository.getAllNodes = vi.fn(); } + if (!mockNodeRepository.getNode) { mockNodeRepository.getNode = vi.fn(); } - describe('constructor', () => { - it('should instantiate when required dependencies are provided', () => { - const mockNodeRepository = {} as any; - const mockEnhancedConfigValidator = {} as any; - - const instance = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator); - expect(instance).toBeDefined(); - }); - }); - - describe('workflow structure validation', () => { - it('should validate structure when workflow has basic fields', () => { - // This is a unit test focused on the structure - const workflow = { - name: 'Test Workflow', - nodes: [ - { - id: '1', - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [250, 300], - parameters: {} - } - ], - connections: {} - }; - - expect(workflow.nodes).toHaveLength(1); - expect(workflow.nodes[0].name).toBe('Start'); - }); - - it('should detect when workflow has no nodes', () => { - const workflow = { - nodes: [], - connections: {} - }; - - expect(workflow.nodes).toHaveLength(0); - }); - - it('should return error when workflow has duplicate node names', () => { - // Arrange - const workflow = { - name: 'Test Workflow with Duplicates', - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [250, 300], - parameters: {} - }, - { - id: '2', - name: 'HTTP Request', // Duplicate name - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [450, 300], - parameters: {} - }, - { - id: '3', - name: 'Set', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [650, 300], - parameters: {} - } - ], - connections: {} - }; - - // Act - simulate validation logic - const nodeNames = new Set(); - const duplicates: string[] = []; - - for (const node of workflow.nodes) { - if (nodeNames.has(node.name)) { - duplicates.push(node.name); - } - nodeNames.add(node.name); + vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => { + if (nodeType === 'n8n-nodes-custom.customNode') { + return { type: 'n8n-nodes-custom.customNode', displayName: 'Custom Node', package: 'n8n-nodes-custom', version: 1, isVersioned: false, properties: [], isAITool: false }; } + return nodeTypes[nodeType] || null; + }); + vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes)); - // Assert - expect(duplicates).toHaveLength(1); - expect(duplicates[0]).toBe('HTTP Request'); + vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ + errors: [], warnings: [], suggestions: [], mode: 'operation' as const, valid: true, visibleProperties: [], hiddenProperties: [], + } as any); + + vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ + valid: true, errors: [], warnings: [], usedVariables: new Set(), usedNodes: new Set(), }); - it('should pass when workflow has unique node names', () => { - // Arrange - const workflow = { - name: 'Test Workflow with Unique Names', - nodes: [ - { - id: '1', - name: 'HTTP Request 1', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [250, 300], - parameters: {} - }, - { - id: '2', - name: 'HTTP Request 2', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [450, 300], - parameters: {} - }, - { - id: '3', - name: 'Set', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [650, 300], - parameters: {} - } - ], - connections: {} - }; + validator = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator); + }); - // Act - const nodeNames = new Set(); - const duplicates: string[] = []; - - for (const node of workflow.nodes) { - if (nodeNames.has(node.name)) { - duplicates.push(node.name); - } - nodeNames.add(node.name); + // ─── Workflow Structure Validation ───────────────────────────────── + + describe('validateWorkflow', () => { + it('should validate a minimal valid workflow', async () => { + const workflow = createWorkflow('Test Workflow').addWebhookNode({ name: 'Webhook' }).build(); + const result = await validator.validateWorkflow(workflow as any); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.statistics.totalNodes).toBe(1); + expect(result.statistics.enabledNodes).toBe(1); + expect(result.statistics.triggerNodes).toBe(1); + }); + + it('should validate a workflow with all options disabled', async () => { + const workflow = createWorkflow('Test Workflow').addWebhookNode({ name: 'Webhook' }).build(); + const result = await validator.validateWorkflow(workflow as any, { validateNodes: false, validateConnections: false, validateExpressions: false }); + expect(result.valid).toBe(true); + expect(mockNodeRepository.getNode).not.toHaveBeenCalled(); + expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled(); + }); + + it('should handle validation errors gracefully', async () => { + const workflow = createWorkflow('Test Workflow').addWebhookNode({ name: 'Webhook' }).build(); + vi.mocked(mockNodeRepository.getNode).mockImplementation(() => { throw new Error('Database error'); }); + const result = await validator.validateWorkflow(workflow as any); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('Database error'))).toBe(true); + }); + + it('should use different validation profiles', async () => { + const workflow = createWorkflow('Test Workflow').addWebhookNode({ name: 'Webhook' }).build(); + for (const profile of ['minimal', 'runtime', 'ai-friendly', 'strict'] as const) { + const result = await validator.validateWorkflow(workflow as any, { profile }); + expect(result).toBeDefined(); + expect(mockEnhancedConfigValidator.validateWithMode).toHaveBeenCalledWith(expect.any(String), expect.any(Object), expect.any(Array), 'operation', profile); } - - // Assert - expect(duplicates).toHaveLength(0); - expect(nodeNames.size).toBe(3); }); - it('should handle edge case when node names differ only by case', () => { - // Arrange - const workflow = { - name: 'Test Workflow with Case Variations', - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [250, 300], - parameters: {} - }, - { - id: '2', - name: 'http request', // Different case - should be allowed - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [450, 300], - parameters: {} - } - ], - connections: {} - }; + it('should handle null workflow gracefully', async () => { + const result = await validator.validateWorkflow(null as any); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true); + }); - // Act - const nodeNames = new Set(); - const duplicates: string[] = []; - - for (const node of workflow.nodes) { - if (nodeNames.has(node.name)) { - duplicates.push(node.name); - } - nodeNames.add(node.name); + it('should handle undefined workflow gracefully', async () => { + const result = await validator.validateWorkflow(undefined as any); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true); + }); + + it('should handle workflow with null nodes array', async () => { + const result = await validator.validateWorkflow({ nodes: null, connections: {} } as any); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('nodes must be an array'))).toBe(true); + }); + + it('should handle workflow with null connections', async () => { + const result = await validator.validateWorkflow({ nodes: [], connections: null } as any); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('connections must be an object'))).toBe(true); + }); + + it('should handle non-array nodes', async () => { + const result = await validator.validateWorkflow({ nodes: 'not-an-array', connections: {} } as any); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('nodes must be an array'); + }); + + it('should handle non-object connections', async () => { + const result = await validator.validateWorkflow({ nodes: [], connections: [] } as any); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('connections must be an object'); + }); + + it('should handle nodes with null/undefined properties', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: null, type: 'n8n-nodes-base.set', position: [0, 0], parameters: undefined }], connections: {} } as any); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should handle circular references in workflow object', async () => { + const workflow: any = { nodes: [], connections: {} }; + workflow.circular = workflow; + await expect(validator.validateWorkflow(workflow)).resolves.toBeDefined(); + }); + }); + + describe('validateWorkflowStructure', () => { + it('should error when nodes array is missing', async () => { + const result = await validator.validateWorkflow({ connections: {} } as any); + expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true); + }); + + it('should error when connections object is missing', async () => { + const result = await validator.validateWorkflow({ nodes: [] } as any); + expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true); + }); + + it('should warn when workflow has no nodes', async () => { + const result = await validator.validateWorkflow({ nodes: [], connections: {} } as any); + expect(result.valid).toBe(true); + expect(result.warnings[0].message).toBe('Workflow is empty - no nodes defined'); + }); + + it('should error for single non-webhook node workflow', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true); + }); + + it('should warn for webhook without connections', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 2 }], connections: {} } as any); + expect(result.valid).toBe(true); + expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true); + }); + + it('should error for multi-node workflow without connections', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true); + }); + + it('should detect duplicate node names', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [300, 100], parameters: {} }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true); + }); + + it('should detect duplicate node IDs', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook1', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '1', name: 'Webhook2', type: 'n8n-nodes-base.webhook', position: [300, 100], parameters: {} }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true); + }); + + it('should count trigger nodes correctly', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Schedule', type: 'n8n-nodes-base.scheduleTrigger', position: [100, 300], parameters: {} }, { id: '3', name: 'Manual', type: 'n8n-nodes-base.manualTrigger', position: [100, 500], parameters: {} }], connections: {} } as any); + expect(result.statistics.triggerNodes).toBe(3); + }); + + it('should warn when no trigger nodes exist', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }, { id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [300, 100], parameters: {} }], connections: { 'Set': { main: [[{ node: 'Code', type: 'main', index: 0 }]] } } } as any); + expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true); + }); + + it('should not count disabled nodes in enabledNodes count', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, disabled: true }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: {} } as any); + expect(result.statistics.totalNodes).toBe(2); + expect(result.statistics.enabledNodes).toBe(1); + }); + + it('should handle very large workflows', async () => { + const nodes = Array(1000).fill(null).map((_, i) => ({ id: `node${i}`, name: `Node ${i}`, type: 'n8n-nodes-base.set', position: [i * 100, 0] as [number, number], parameters: {} })); + const connections: any = {}; + for (let i = 0; i < 999; i++) { connections[`Node ${i}`] = { main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]] }; } + const start = Date.now(); + const result = await validator.validateWorkflow({ nodes, connections } as any); + expect(result).toBeDefined(); + expect(Date.now() - start).toBeLessThan(process.env.CI ? 10000 : 5000); + }); + + it('should handle invalid position values', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'InvalidPos', type: 'n8n-nodes-base.set', position: 'invalid' as any, parameters: {} }, { id: '2', name: 'NaNPos', type: 'n8n-nodes-base.set', position: [NaN, NaN] as [number, number], parameters: {} }], connections: {} } as any); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should handle very long node names', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'A'.repeat(1000), type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} }], connections: {} } as any); + expect(result.warnings.some(w => w.message.includes('very long'))).toBe(true); + }); + }); + + // ─── Node Validation ─────────────────────────────────────────────── + + describe('validateAllNodes', () => { + it('should skip disabled nodes', async () => { + await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, disabled: true }], connections: {} } as any); + expect(mockNodeRepository.getNode).not.toHaveBeenCalled(); + }); + + it('should accept both nodes-base and n8n-nodes-base prefixes', async () => { + (mockNodeRepository.getNode as any) = vi.fn((type: string) => type === 'nodes-base.webhook' ? { nodeType: 'nodes-base.webhook', displayName: 'Webhook', properties: [], isVersioned: false } : null); + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'nodes-base.webhook', position: [100, 100], parameters: {} }], connections: {} } as any); + expect(result.valid).toBe(true); + }); + + it('should try normalized types for n8n-nodes-base', async () => { + await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }], connections: {} } as any); + expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook'); + }); + + it('should validate typeVersion but skip parameter validation for langchain nodes', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1, position: [100, 100], parameters: {} }], connections: {} } as any); + expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent'); + expect(result.errors.filter(e => e.message.includes('typeVersion'))).toEqual([]); + }); + + it('should catch invalid typeVersion for langchain nodes', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 99999, position: [100, 100], parameters: {} }], connections: {} } as any); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('typeVersion 99999 exceeds maximum'))).toBe(true); + }); + + it('should error for missing typeVersion on versioned nodes', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes("Missing required property 'typeVersion'"))).toBe(true); + }); + + it('should error for invalid typeVersion', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 'invalid' as any }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true); + }); + + it('should warn for outdated typeVersion', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 1 }], connections: {} } as any); + expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true); + }); + + it('should error for typeVersion exceeding maximum', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 10 }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true); + }); + + it('should add node validation errors and warnings', async () => { + vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }], warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }], suggestions: [], mode: 'operation' as const, valid: false, visibleProperties: [], hiddenProperties: [] } as any); + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, typeVersion: 4 }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true); + expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true); + }); + + it('should handle node validation failures gracefully', async () => { + vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockImplementation(() => { throw new Error('Validation error'); }); + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, typeVersion: 4 }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true); + }); + + it('should handle repository errors gracefully', async () => { + vi.mocked(mockNodeRepository.getNode).mockImplementation(() => { throw new Error('Database connection failed'); }); + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }], connections: {} } as any); + expect(result).toHaveProperty('valid'); + expect(Array.isArray(result.errors)).toBe(true); + }); + }); + + // ─── Connection Validation ───────────────────────────────────────── + + describe('validateConnections', () => { + it('should validate valid connections', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any); + expect(result.statistics.validConnections).toBe(1); + expect(result.statistics.invalidConnections).toBe(0); + }); + + it('should error for connection from non-existent node', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }], connections: { 'NonExistent': { main: [[{ node: 'Webhook', type: 'main', index: 0 }]] } } } as any); + expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true); + }); + + it('should error when using node ID instead of name in source', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: 'webhook-id', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: 'set-id', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: { 'webhook-id': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any); + expect(result.errors.some(e => e.message.includes("Connection uses node ID 'webhook-id' instead of node name 'Webhook'"))).toBe(true); + }); + + it('should error for connection to non-existent node', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] } } } as any); + expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true); + }); + + it('should error when using node ID instead of name in target', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: 'webhook-id', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: 'set-id', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'set-id', type: 'main', index: 0 }]] } } } as any); + expect(result.errors.some(e => e.message.includes("Connection target uses node ID 'set-id' instead of node name 'Set'"))).toBe(true); + }); + + it('should warn for connection to disabled node', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {}, disabled: true }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any); + expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true); + }); + + it('should detect self-referencing nodes', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'SelfLoop', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }], connections: { 'SelfLoop': { main: [[{ node: 'SelfLoop', type: 'main', index: 0 }]] } } } as any); + expect(result.warnings.some(w => w.message.includes('self-referencing'))).toBe(true); + }); + + it('should handle invalid connection formats', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }], connections: { 'Node1': { main: 'invalid-format' as any } } } as any); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should handle negative output indices', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }, { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [100, 0], parameters: {} }], connections: { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: -1 }]] } } } as any); + expect(result.errors.some(e => e.message.includes('Invalid'))).toBe(true); + }); + + it('should validate error outputs', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, { id: '2', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: { 'HTTP': { error: [[{ node: 'Error Handler', type: 'main', index: 0 }]] } } } as any); + expect(result.statistics.validConnections).toBe(1); + }); + + it('should validate AI tool connections', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 100], parameters: {} }, { id: '2', name: 'Tool', type: 'n8n-nodes-base.httpRequest', position: [300, 100], parameters: {} }], connections: { 'Agent': { ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]] } } } as any); + expect(result.statistics.validConnections).toBe(1); + }); + + it('should warn for orphaned nodes', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, { id: '3', name: 'Orphaned', type: 'n8n-nodes-base.code', position: [500, 100], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any); + expect(result.warnings.some(w => w.message.includes('not reachable from any trigger node') && w.nodeName === 'Orphaned')).toBe(true); + }); + + it('should detect cycles in workflow', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }, { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, { id: '3', name: 'Node3', type: 'n8n-nodes-base.set', position: [500, 100], parameters: {} }], connections: { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: 0 }]] }, 'Node2': { main: [[{ node: 'Node3', type: 'main', index: 0 }]] }, 'Node3': { main: [[{ node: 'Node1', type: 'main', index: 0 }]] } } } as any); + expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true); + }); + + it('should handle null connections properly', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'IF', type: 'n8n-nodes-base.if', position: [100, 100], parameters: {}, typeVersion: 2 }, { id: '2', name: 'True Branch', type: 'n8n-nodes-base.set', position: [300, 50], parameters: {}, typeVersion: 3 }], connections: { 'IF': { main: [[{ node: 'True Branch', type: 'main', index: 0 }], null] } } } as any); + expect(result.statistics.validConnections).toBe(1); + }); + + it('should continue validation after encountering errors', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: null as any, type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }, { id: '2', name: 'Valid', type: 'n8n-nodes-base.set', position: [100, 0], parameters: {} }, { id: '3', name: 'AlsoValid', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }], connections: { 'Valid': { main: [[{ node: 'AlsoValid', type: 'main', index: 0 }]] } } } as any); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.statistics.validConnections).toBeGreaterThan(0); + }); + }); + + // ─── Expression Validation ───────────────────────────────────────── + + describe('validateExpressions', () => { + it('should validate expressions in node parameters', async () => { + await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: { values: { string: [{ name: 'field', value: '={{ $json.data }}' }] } } }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any); + expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith(expect.objectContaining({ values: expect.any(Object) }), expect.objectContaining({ currentNodeName: 'Set', hasInputData: true })); + }); + + it('should add expression errors to result', async () => { + vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ valid: false, errors: ['Invalid expression syntax'], warnings: ['Deprecated variable usage'], usedVariables: new Set(['$json']), usedNodes: new Set() }); + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: { value: '={{ invalid }}' } }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true); + expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true); + }); + + it('should skip expression validation for disabled nodes', async () => { + await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: { value: '={{ $json.data }}' }, disabled: true }], connections: {} } as any); + expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled(); + }); + + it('should skip expression validation when option is false', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: { value: '{{ $json.data }}' } }], connections: {} } as any, { validateExpressions: false }); + expect(result.statistics.expressionsValidated).toBe(0); + }); + }); + + // ─── Expression Format Detection ────────────────────────────────── + + describe('Expression Format Detection', () => { + it('should detect missing = prefix in simple expressions', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Send Email', type: 'n8n-nodes-base.emailSend', position: [0, 0], parameters: { fromEmail: '{{ $env.SENDER_EMAIL }}', toEmail: 'user@example.com', subject: 'Test' }, typeVersion: 2.1 }], connections: {} } as any); + expect(result.valid).toBe(false); + const formatErrors = result.errors.filter(e => e.message.includes('Expression format error')); + expect(formatErrors).toHaveLength(1); + expect(formatErrors[0].message).toContain('fromEmail'); + expect(formatErrors[0].message).toContain('={{ $env.SENDER_EMAIL }}'); + }); + + it('should detect missing resource locator format for GitHub fields', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'GitHub', type: 'n8n-nodes-base.github', position: [0, 0], parameters: { operation: 'createComment', owner: '{{ $vars.GITHUB_OWNER }}', repository: '{{ $vars.GITHUB_REPO }}', issueNumber: 123, body: 'Test' }, typeVersion: 1.1 }], connections: {} } as any); + expect(result.valid).toBe(false); + expect(result.errors.find(e => e.message.includes('owner'))?.message).toContain('resource locator format'); + }); + + it('should detect mixed content without prefix', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: { url: 'https://api.example.com/{{ $json.endpoint }}' }, typeVersion: 4 }], connections: {} } as any); + const urlError = result.errors.find(e => e.message.includes('Expression format') && e.message.includes('url')); + expect(urlError).toBeTruthy(); + expect(urlError?.message).toContain('=https://api.example.com/{{ $json.endpoint }}'); + }); + + it('should accept properly formatted expressions', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Send Email', type: 'n8n-nodes-base.emailSend', position: [0, 0], parameters: { fromEmail: '={{ $env.SENDER_EMAIL }}', toEmail: 'user@example.com', subject: '=Test {{ $json.type }}' }, typeVersion: 2.1 }], connections: {} } as any); + expect(result.errors.filter(e => e.message.includes('Expression format'))).toHaveLength(0); + }); + + it('should accept resource locator format', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'GitHub', type: 'n8n-nodes-base.github', position: [0, 0], parameters: { operation: 'createComment', owner: { __rl: true, value: '={{ $vars.GITHUB_OWNER }}', mode: 'expression' }, repository: { __rl: true, value: '={{ $vars.GITHUB_REPO }}', mode: 'expression' }, issueNumber: 123, body: '=Test from {{ $json.author }}' }, typeVersion: 1.1 }], connections: {} } as any); + expect(result.errors.filter(e => e.message.includes('Expression format'))).toHaveLength(0); + }); + + it('should provide clear fix examples in error messages', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Process Data', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: { url: 'https://api.example.com/users/{{ $json.userId }}' }, typeVersion: 4 }], connections: {} } as any); + const error = result.errors.find(e => e.message.includes('Expression format')); + expect(error?.message).toContain('Current (incorrect):'); + expect(error?.message).toContain('Fixed (correct):'); + }); + }); + + // ─── Error Handler Detection ─────────────────────────────────────── + + describe('Error Handler Detection', () => { + it('should identify error handlers by node name patterns', async () => { + for (const errorName of ['Error Handler', 'Handle Error', 'Catch Exception', 'Failure Response']) { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: errorName, type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} }], connections: { 'Source': { main: [[{ node: 'Success', type: 'main', index: 0 }, { node: errorName, type: 'main', index: 0 }]] } } } as any); + expect(result.errors.some(e => e.message.includes('Incorrect error output configuration') && e.message.includes(errorName))).toBe(true); } - - // Assert - case-sensitive comparison should allow both - expect(duplicates).toHaveLength(0); - expect(nodeNames.size).toBe(2); - }); - }); - - describe('connection validation logic', () => { - it('should validate structure when connections are properly formatted', () => { - const connections = { - 'Node1': { - main: [[{ node: 'Node2', type: 'main', index: 0 }]] - } - }; - - expect(connections['Node1']).toBeDefined(); - expect(connections['Node1'].main).toHaveLength(1); }); - it('should detect when node has self-referencing connection', () => { - const connections = { - 'Node1': { - main: [[{ node: 'Node1', type: 'main', index: 0 }]] - } - }; - - const targetNode = connections['Node1'].main![0][0].node; - expect(targetNode).toBe('Node1'); - }); - }); - - describe('node validation logic', () => { - it('should validate when node has all required fields', () => { - const node = { - id: '1', - name: 'Test Node', - type: 'n8n-nodes-base.function', - position: [100, 100], - parameters: {} - }; - - expect(node.id).toBeDefined(); - expect(node.name).toBeDefined(); - expect(node.type).toBeDefined(); - expect(node.position).toHaveLength(2); - }); - }); - - describe('expression validation logic', () => { - it('should identify expressions when text contains n8n syntax', () => { - const expressions = [ - '{{ $json.field }}', - 'regular text', - '{{ $node["Webhook"].json.data }}' - ]; - - const n8nExpressions = expressions.filter(expr => - expr.includes('{{') && expr.includes('}}') - ); - - expect(n8nExpressions).toHaveLength(2); - }); - }); - - describe('AI tool validation', () => { - it('should identify AI nodes when type includes langchain', () => { - const nodes = [ - { type: '@n8n/n8n-nodes-langchain.agent' }, - { type: 'n8n-nodes-base.httpRequest' }, - { type: '@n8n/n8n-nodes-langchain.llm' } - ]; - - const aiNodes = nodes.filter(node => - node.type.includes('langchain') - ); - - expect(aiNodes).toHaveLength(2); - }); - }); - - describe('validation options', () => { - it('should support profiles when different validation levels are needed', () => { - const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict']; - - expect(profiles).toContain('minimal'); - expect(profiles).toContain('runtime'); - }); - }); - - describe('duplicate node ID validation', () => { - it('should detect duplicate node IDs and provide helpful context', () => { - const workflow = { - name: 'Test Workflow with Duplicate IDs', - nodes: [ - { - id: 'abc123', - name: 'First Node', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [250, 300], - parameters: {} - }, - { - id: 'abc123', // Duplicate ID - name: 'Second Node', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [450, 300], - parameters: {} - } - ], - connections: {} - }; - - // Simulate validation logic - const nodeIds = new Set(); - const nodeIdToIndex = new Map(); - const errors: Array<{ message: string }> = []; - - for (let i = 0; i < workflow.nodes.length; i++) { - const node = workflow.nodes[i]; - if (nodeIds.has(node.id)) { - const firstNodeIndex = nodeIdToIndex.get(node.id); - const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined; - - errors.push({ - message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")` - }); - } else { - nodeIds.add(node.id); - nodeIdToIndex.set(node.id, i); - } + it('should not flag success node names as error handlers', async () => { + for (const name of ['Process Data', 'Transform', 'Normal Flow']) { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'First', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: name, type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} }], connections: { 'Source': { main: [[{ node: 'First', type: 'main', index: 0 }, { node: name, type: 'main', index: 0 }]] } } } as any); + expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false); } + }); + it('should generate valid JSON in error messages', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'API Call', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.respondToWebhook', position: [200, 100], parameters: {} }], connections: { 'API Call': { main: [[{ node: 'Success', type: 'main', index: 0 }, { node: 'Error Handler', type: 'main', index: 0 }]] } } } as any); + const errorMsg = result.errors.find(e => e.message.includes('Incorrect error output configuration')); + expect(errorMsg).toBeDefined(); + expect(errorMsg!.message).toContain('INCORRECT (current):'); + expect(errorMsg!.message).toContain('CORRECT (should be):'); + }); + }); + + // ─── onError Property Validation ─────────────────────────────────── + + describe('onError Property Validation', () => { + it('should validate onError property combinations', async () => { + // onError set but no error connections -> error + const r1 = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {}, onError: 'continueErrorOutput' }, { id: '2', name: 'Next', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }], connections: { 'Test': { main: [[{ node: 'Next', type: 'main', index: 0 }]] } } } as any); + expect(r1.errors.some(e => e.message.includes("has onError: 'continueErrorOutput' but no error output connections"))).toBe(true); + + // error connections but no onError -> warning + const r2 = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'ErrH', type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} }], connections: { 'Test': { main: [[{ node: 'Success', type: 'main', index: 0 }], [{ node: 'ErrH', type: 'main', index: 0 }]] } } } as any); + expect(r2.warnings.some(w => w.message.includes('error output connections in main[1] but missing onError'))).toBe(true); + }); + + it('should only flag continueErrorOutput without error connections', async () => { + for (const val of ['continueRegularOutput', 'stopWorkflow']) { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {}, onError: val }, { id: '2', name: 'Next', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }], connections: { 'Test': { main: [[{ node: 'Next', type: 'main', index: 0 }]] } } } as any); + expect(result.errors.some(e => e.message.includes('but no error output connections'))).toBe(false); + } + }); + }); + + // ─── Workflow Patterns ───────────────────────────────────────────── + + describe('checkWorkflowPatterns', () => { + it('should suggest error handling for large workflows', async () => { + const builder = createWorkflow('Large'); + for (let i = 0; i < 5; i++) builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Set${i}` }); + expect((await validator.validateWorkflow(builder.build() as any)).warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true); + }); + + it('should warn about long linear chains', async () => { + const builder = createWorkflow('Linear'); + const names: string[] = []; + for (let i = 0; i < 12; i++) { const n = `Node${i}`; builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: n }); names.push(n); } + builder.connectSequentially(names); + expect((await validator.validateWorkflow(builder.build() as any)).warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true); + }); + + it('should warn about AI agents without tools', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 100], parameters: {} }], connections: {} } as any); + expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true); + }); + + it('should NOT warn about AI agents WITH tools', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Tool', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [300, 100], parameters: {} }], connections: { 'Tool': { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any); + expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(false); + }); + }); + + // ─── Node Error Handling ─────────────────────────────────────────── + + describe('checkNodeErrorHandling', () => { + it('should error when node-level properties are inside parameters', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], typeVersion: 4, parameters: { url: 'https://api.example.com', onError: 'continueRegularOutput', retryOnFail: true, credentials: {} } }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true); + }); + + it('should validate onError property values', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'invalidValue' as any }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true); + }); + + it('should warn about deprecated continueOnFail', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, continueOnFail: true }], connections: {} } as any); + expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true); + }); + + it('should error for conflicting error handling properties', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, continueOnFail: true, onError: 'continueRegularOutput' }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true); + }); + + it('should validate retry configuration', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, retryOnFail: true, maxTries: 'invalid' as any, waitBetweenTries: -1000 }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true); + expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true); + }); + + it('should validate other node-level properties', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {}, typeVersion: 3, alwaysOutputData: 'invalid' as any, executeOnce: 'invalid' as any, disabled: 'invalid' as any, notesInFlow: 'invalid' as any, notes: 123 as any }], connections: {} } as any); + expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true); + expect(result.errors.some(e => e.message.includes('executeOnce must be a boolean'))).toBe(true); + expect(result.errors.some(e => e.message.includes('disabled must be a boolean'))).toBe(true); + }); + }); + + // ─── Trigger Reachability ────────────────────────────────────────── + + describe('Trigger reachability', () => { + it('should flag disconnected subgraph as unreachable', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} }, { id: '2', name: 'Connected', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'Island1', type: 'n8n-nodes-base.code', position: [0, 300], parameters: {} }, { id: '4', name: 'Island2', type: 'n8n-nodes-base.set', position: [200, 300], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'Connected', type: 'main', index: 0 }]] }, 'Island1': { main: [[{ node: 'Island2', type: 'main', index: 0 }]] } } } as any); + const unreachable = result.warnings.filter(w => w.message.includes('not reachable from any trigger')); + expect(unreachable.length).toBe(2); + }); + + it('should not flag disabled nodes or sticky notes', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'Disabled', type: 'n8n-nodes-base.code', position: [500, 500], parameters: {}, disabled: true }, { id: '4', name: 'Note', type: 'n8n-nodes-base.stickyNote', position: [500, 600], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any); + expect(result.warnings.filter(w => w.nodeName === 'Disabled' || w.nodeName === 'Note')).toHaveLength(0); + }); + }); + + // ─── Tool Variant Validation ─────────────────────────────────────── + + describe('Tool Variant Validation', () => { + let toolVariantRepo: NodeRepository; + + beforeEach(() => { + toolVariantRepo = { getNode: vi.fn((t: string) => { + const m: Record = { + 'nodes-base.supabase': { nodeType: 'nodes-base.supabase', displayName: 'Supabase', isAITool: true, hasToolVariant: true, isToolVariant: false, properties: [] }, + 'nodes-base.supabaseTool': { nodeType: 'nodes-base.supabaseTool', displayName: 'Supabase Tool', isAITool: true, hasToolVariant: false, isToolVariant: true, toolVariantOf: 'nodes-base.supabase', properties: [] }, + 'nodes-langchain.toolCalculator': { nodeType: 'nodes-langchain.toolCalculator', displayName: 'Calculator', isAITool: true, properties: [] }, + 'nodes-base.httpRequest': { nodeType: 'nodes-base.httpRequest', displayName: 'HTTP Request', isAITool: false, hasToolVariant: false, isToolVariant: false, properties: [] }, + 'nodes-base.googleDrive': { nodeType: 'nodes-base.googleDrive', displayName: 'Google Drive', isAITool: false, hasToolVariant: false, isToolVariant: false, properties: [] }, + 'nodes-base.googleSheets': { nodeType: 'nodes-base.googleSheets', displayName: 'Google Sheets', isAITool: false, hasToolVariant: false, isToolVariant: false, properties: [] }, + 'nodes-langchain.agent': { nodeType: 'nodes-langchain.agent', displayName: 'AI Agent', properties: [] }, + }; + return m[t] || null; + }) } as any; + validator = new WorkflowValidator(toolVariantRepo, mockEnhancedConfigValidator); + }); + + it('should pass for langchain tool nodes', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Calc', type: 'n8n-nodes-langchain.toolCalculator', typeVersion: 1.2, position: [250, 300], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: {} }], connections: { Calc: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any); + expect(result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL')).toHaveLength(0); + }); + + it('should pass for Tool variant nodes', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Supabase Tool', type: 'n8n-nodes-base.supabaseTool', typeVersion: 1, position: [250, 300], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: {} }], connections: { 'Supabase Tool': { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any); + expect(result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL')).toHaveLength(0); + }); + + it('should fail when base node is used instead of Tool variant', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Supabase', type: 'n8n-nodes-base.supabase', typeVersion: 1, position: [250, 300], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: {} }], connections: { Supabase: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any); + const errors = result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'); expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Duplicate node ID: "abc123"'); - expect(errors[0].message).toContain('index 1'); - expect(errors[0].message).toContain('Second Node'); - expect(errors[0].message).toContain('n8n-nodes-base.set'); - expect(errors[0].message).toContain('index 0'); - expect(errors[0].message).toContain('First Node'); + expect((errors[0] as any).fix?.suggestedType).toBe('n8n-nodes-base.supabaseTool'); }); - it('should include UUID generation example in error message context', () => { - const workflow = { - name: 'Test', - nodes: [ - { id: 'dup', name: 'A', type: 'n8n-nodes-base.webhook', typeVersion: 1, position: [0, 0], parameters: {} }, - { id: 'dup', name: 'B', type: 'n8n-nodes-base.webhook', typeVersion: 1, position: [0, 0], parameters: {} } - ], - connections: {} - }; - - // Error message should contain UUID example pattern - const expectedPattern = /crypto\.randomUUID\(\)/; - // This validates that our implementation uses the pattern - expect(expectedPattern.test('crypto.randomUUID()')).toBe(true); + it('should not error for base nodes without ai_tool connections', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Supabase', type: 'n8n-nodes-base.supabase', typeVersion: 1, position: [250, 300], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 1, position: [450, 300], parameters: {} }], connections: { Supabase: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any); + expect(result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL')).toHaveLength(0); }); - it('should detect multiple nodes with the same duplicate ID', () => { - // Edge case: Three or more nodes with the same ID - const workflow = { - name: 'Test Workflow with Multiple Duplicates', - nodes: [ - { - id: 'shared-id', - name: 'First Node', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [250, 300], - parameters: {} - }, - { - id: 'shared-id', // Duplicate 1 - name: 'Second Node', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [450, 300], - parameters: {} - }, - { - id: 'shared-id', // Duplicate 2 - name: 'Third Node', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [650, 300], - parameters: {} - } - ], - connections: {} - }; - - // Simulate validation logic - const nodeIds = new Set(); - const nodeIdToIndex = new Map(); - const errors: Array<{ message: string }> = []; - - for (let i = 0; i < workflow.nodes.length; i++) { - const node = workflow.nodes[i]; - if (nodeIds.has(node.id)) { - const firstNodeIndex = nodeIdToIndex.get(node.id); - const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined; - - errors.push({ - message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")` - }); - } else { - nodeIds.add(node.id); - nodeIdToIndex.set(node.id, i); - } - } - - // Should report 2 errors (nodes at index 1 and 2 both conflict with node at index 0) - expect(errors).toHaveLength(2); - expect(errors[0].message).toContain('index 1'); - expect(errors[0].message).toContain('Second Node'); - expect(errors[1].message).toContain('index 2'); - expect(errors[1].message).toContain('Third Node'); + it('should not error when base node without Tool variant uses ai_tool', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', typeVersion: 1, position: [250, 300], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: {} }], connections: { 'HTTP': { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any); + expect(result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL')).toHaveLength(0); + expect(result.errors.filter(e => e.code === 'INVALID_AI_TOOL_SOURCE').length).toBeGreaterThan(0); }); - it('should handle duplicate IDs with same node type', () => { - // Edge case: Both nodes are the same type - const workflow = { - name: 'Test Workflow with Same Type Duplicates', - nodes: [ - { - id: 'duplicate-slack', - name: 'Slack Send 1', - type: 'n8n-nodes-base.slack', - typeVersion: 2, - position: [250, 300], - parameters: {} - }, - { - id: 'duplicate-slack', - name: 'Slack Send 2', - type: 'n8n-nodes-base.slack', - typeVersion: 2, - position: [450, 300], - parameters: {} - } - ], - connections: {} - }; - - // Simulate validation logic - const nodeIds = new Set(); - const nodeIdToIndex = new Map(); - const errors: Array<{ message: string }> = []; - - for (let i = 0; i < workflow.nodes.length; i++) { - const node = workflow.nodes[i]; - if (nodeIds.has(node.id)) { - const firstNodeIndex = nodeIdToIndex.get(node.id); - const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined; - - errors.push({ - message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")` - }); - } else { - nodeIds.add(node.id); - nodeIdToIndex.set(node.id, i); - } - } - - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Duplicate node ID: "duplicate-slack"'); - expect(errors[0].message).toContain('Slack Send 2'); - expect(errors[0].message).toContain('Slack Send 1'); - // Both should show the same type - expect(errors[0].message).toMatch(/n8n-nodes-base\.slack.*n8n-nodes-base\.slack/s); + it('should infer googleDriveTool when googleDrive exists', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'GDT', type: 'n8n-nodes-base.googleDriveTool', typeVersion: 3, position: [250, 300], parameters: {} }], connections: {} } as any); + expect(result.errors.filter(e => e.message?.includes('Unknown node type'))).toHaveLength(0); + expect(result.warnings.filter(e => (e as any).code === 'INFERRED_TOOL_VARIANT')).toHaveLength(1); }); - it('should handle duplicate IDs with empty node names gracefully', () => { - // Edge case: Empty string node names - const workflow = { - name: 'Test Workflow with Empty Names', - nodes: [ - { - id: 'empty-name-id', - name: '', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [250, 300], - parameters: {} - }, - { - id: 'empty-name-id', - name: '', - type: 'n8n-nodes-base.set', - typeVersion: 2, - position: [450, 300], - parameters: {} - } - ], - connections: {} - }; - - // Simulate validation logic with safe fallback - const nodeIds = new Set(); - const nodeIdToIndex = new Map(); - const errors: Array<{ message: string }> = []; - - for (let i = 0; i < workflow.nodes.length; i++) { - const node = workflow.nodes[i]; - if (nodeIds.has(node.id)) { - const firstNodeIndex = nodeIdToIndex.get(node.id); - const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined; - - errors.push({ - message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")` - }); - } else { - nodeIds.add(node.id); - nodeIdToIndex.set(node.id, i); - } - } - - // Should not crash and should use empty string in message - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Duplicate node ID'); - expect(errors[0].message).toContain('name: ""'); + it('should error for unknownNodeTool when base does not exist', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Unknown', type: 'n8n-nodes-base.nonExistentNodeTool', typeVersion: 1, position: [250, 300], parameters: {} }], connections: {} } as any); + expect(result.errors.filter(e => e.message?.includes('Unknown node type'))).toHaveLength(1); }); - it('should handle duplicate IDs with missing node properties', () => { - // Edge case: Node with undefined type or name - const workflow = { - name: 'Test Workflow with Missing Properties', - nodes: [ - { - id: 'missing-props', - name: 'Valid Node', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 3, - position: [250, 300], - parameters: {} - }, - { - id: 'missing-props', - name: undefined as any, - type: undefined as any, - typeVersion: 2, - position: [450, 300], - parameters: {} - } - ], - connections: {} - }; - - // Simulate validation logic with safe fallbacks - const nodeIds = new Set(); - const nodeIdToIndex = new Map(); - const errors: Array<{ message: string }> = []; - - for (let i = 0; i < workflow.nodes.length; i++) { - const node = workflow.nodes[i]; - if (nodeIds.has(node.id)) { - const firstNodeIndex = nodeIdToIndex.get(node.id); - const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined; - - errors.push({ - message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")` - }); - } else { - nodeIds.add(node.id); - nodeIdToIndex.set(node.id, i); - } - } - - // Should use fallback values without crashing - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('Duplicate node ID: "missing-props"'); - expect(errors[0].message).toContain('name: "undefined"'); - expect(errors[0].message).toContain('type: "undefined"'); + it('should prefer database record over inference for supabaseTool', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'ST', type: 'n8n-nodes-base.supabaseTool', typeVersion: 1, position: [250, 300], parameters: {} }], connections: {} } as any); + expect(result.errors.filter(e => e.message?.includes('Unknown node type'))).toHaveLength(0); + expect(result.warnings.filter(e => (e as any).code === 'INFERRED_TOOL_VARIANT')).toHaveLength(0); }); }); -}); \ No newline at end of file + + // ─── AI Sub-Node Main Connection Detection ───────────────────────── + + describe('AI Sub-Node Main Connection Detection', () => { + function makeAIWorkflow(sourceType: string, sourceName: string) { + return { nodes: [{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} }, { id: '2', name: sourceName, type: sourceType, position: [200, 0], parameters: {} }, { id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} }], connections: { 'Manual Trigger': { main: [[{ node: sourceName, type: 'main', index: 0 }]] }, [sourceName]: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } }; + } + + it('should flag LLM node connected via main', async () => { + const result = await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.lmChatGoogleGemini', 'Gemini') as any); + const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION'); + expect(error).toBeDefined(); + expect(error!.message).toContain('ai_languageModel'); + }); + + it('should flag memory node connected via main', async () => { + const result = await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.memoryBufferWindow', 'Memory') as any); + expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')?.message).toContain('ai_memory'); + }); + + it('should flag embeddings node connected via main', async () => { + const result = await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.embeddingsOpenAi', 'Embed') as any); + expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')?.message).toContain('ai_embedding'); + }); + + it('should NOT flag regular langchain nodes via main', async () => { + expect((await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.agent', 'Agent') as any)).errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); + expect((await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.openAi', 'OpenAI') as any)).errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); + }); + + it('should NOT flag dynamic-output nodes', async () => { + expect((await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.textClassifier', 'TC') as any)).errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); + expect((await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.vectorStoreInMemory', 'VS') as any)).errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); + }); + + it('should NOT flag sub-node connected via correct AI type', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [200, 0], parameters: {} }, { id: '3', name: 'Gemini', type: '@n8n/n8n-nodes-langchain.lmChatGoogleGemini', position: [200, 200], parameters: {} }], connections: { 'Trigger': { main: [[{ node: 'Agent', type: 'main', index: 0 }]] }, 'Gemini': { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] } } } as any); + expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined(); + }); + }); + + // ─── Suggestions ─────────────────────────────────────────────────── + + describe('generateSuggestions', () => { + it('should suggest adding trigger', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }], connections: {} } as any); + expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true); + }); + + it('should provide connection examples', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: {} } as any); + expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true); + }); + + it('should suggest breaking up large workflows', async () => { + const builder = createWorkflow('Large'); + for (let i = 0; i < 25; i++) builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `N${i}` }); + expect((await validator.validateWorkflow(builder.build() as any)).suggestions.some(s => s.includes('Consider breaking this workflow'))).toBe(true); + }); + }); + + // ─── Validation Options ──────────────────────────────────────────── + + describe('Validation Options', () => { + it('should validate connections only', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'N1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }, { id: '2', name: 'N2', type: 'n8n-nodes-base.set', position: [100, 0], parameters: {} }], connections: { 'N1': { main: [[{ node: 'N2', type: 'main', index: 0 }]] } } } as any, { validateNodes: false, validateExpressions: false, validateConnections: true }); + expect(result.statistics.validConnections).toBe(1); + }); + + it('should validate expressions only', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'N1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: { value: '{{ $json.data }}' } }], connections: {} } as any, { validateNodes: false, validateExpressions: true, validateConnections: false }); + expect(result.statistics.expressionsValidated).toBeGreaterThan(0); + }); + }); + + // ─── Integration Tests ───────────────────────────────────────────── + + describe('Integration Tests', () => { + it('should validate a complex workflow with multiple issues', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 2 }, { id: '2', name: 'HTTP1', type: 'nodes-base.httpRequest', position: [300, 100], parameters: {} }, { id: '3', name: 'Slack', type: 'n8n-nodes-base.slack', position: [500, 100], parameters: {} }, { id: '4', name: 'Disabled', type: 'n8n-nodes-base.set', position: [700, 100], parameters: {}, disabled: true }, { id: '5', name: 'HTTP2', type: 'n8n-nodes-base.httpRequest', position: [900, 100], parameters: { onError: 'continueRegularOutput' }, typeVersion: 4 }, { id: '6', name: 'Orphaned', type: 'n8n-nodes-base.code', position: [1100, 100], parameters: {}, typeVersion: 2 }, { id: '7', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 300], parameters: {}, typeVersion: 1 }], connections: { 'Webhook': { main: [[{ node: 'HTTP1', type: 'main', index: 0 }]] }, 'HTTP1': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] }, 'Slack': { main: [[{ node: 'Disabled', type: 'main', index: 0 }]] }, '5': { main: [[{ node: 'Agent', type: 'main', index: 0 }]] } } } as any); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes("Missing required property 'typeVersion'"))).toBe(true); + expect(result.errors.some(e => e.message.includes('Node-level properties onError are in the wrong location'))).toBe(true); + expect(result.errors.some(e => e.message.includes("Connection uses node ID '5'"))).toBe(true); + expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true); + expect(result.statistics.totalNodes).toBe(7); + }); + + it('should validate a perfect workflow', async () => { + const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [250, 300], parameters: {}, typeVersion: 1 }, { id: '2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [450, 300], parameters: { url: 'https://api.example.com', method: 'GET' }, typeVersion: 4, onError: 'continueErrorOutput', retryOnFail: true, maxTries: 3, waitBetweenTries: 1000 }, { id: '3', name: 'Process', type: 'n8n-nodes-base.code', position: [650, 300], parameters: { jsCode: 'return items;' }, typeVersion: 2 }, { id: '4', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [650, 500], parameters: {}, typeVersion: 3 }], connections: { 'Manual Trigger': { main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] }, 'HTTP Request': { main: [[{ node: 'Process', type: 'main', index: 0 }], [{ node: 'Error Handler', type: 'main', index: 0 }]] } } } as any); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + expect(result.statistics.validConnections).toBe(3); + }); + }); +}); diff --git a/tests/unit/telemetry/event-validator.test.ts b/tests/unit/telemetry/event-validator.test.ts deleted file mode 100644 index 99ec4c7..0000000 --- a/tests/unit/telemetry/event-validator.test.ts +++ /dev/null @@ -1,562 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { z } from 'zod'; -import { TelemetryEventValidator, telemetryEventSchema, workflowTelemetrySchema } from '../../../src/telemetry/event-validator'; -import { TelemetryEvent, WorkflowTelemetry } from '../../../src/telemetry/telemetry-types'; - -// Mock logger to avoid console output in tests -vi.mock('../../../src/utils/logger', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } -})); - -describe('TelemetryEventValidator', () => { - let validator: TelemetryEventValidator; - - beforeEach(() => { - validator = new TelemetryEventValidator(); - vi.clearAllMocks(); - }); - - describe('validateEvent()', () => { - it('should validate a basic valid event', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'tool_used', - properties: { tool: 'httpRequest', success: true, duration: 500 } - }; - - const result = validator.validateEvent(event); - expect(result).toEqual(event); - }); - - it('should validate event with specific schema for tool_used', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'tool_used', - properties: { tool: 'httpRequest', success: true, duration: 500 } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(result?.properties.tool).toBe('httpRequest'); - expect(result?.properties.success).toBe(true); - expect(result?.properties.duration).toBe(500); - }); - - it('should validate search_query event with specific schema', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'search_query', - properties: { - query: 'test query', - resultsFound: 5, - searchType: 'nodes', - hasResults: true, - isZeroResults: false - } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(result?.properties.query).toBe('test query'); - expect(result?.properties.resultsFound).toBe(5); - expect(result?.properties.hasResults).toBe(true); - }); - - it('should validate performance_metric event with specific schema', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'performance_metric', - properties: { - operation: 'database_query', - duration: 1500, - isSlow: true, - isVerySlow: false, - metadata: { table: 'nodes' } - } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(result?.properties.operation).toBe('database_query'); - expect(result?.properties.duration).toBe(1500); - expect(result?.properties.isSlow).toBe(true); - }); - - it('should sanitize sensitive data from properties', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'generic_event', - properties: { - description: 'Visit https://example.com/secret and user@example.com with key abcdef123456789012345678901234567890', - apiKey: 'super-secret-key-12345678901234567890', - normalProp: 'normal value' - } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(result?.properties.description).toBe('Visit [URL] and [EMAIL] with key [KEY]'); - expect(result?.properties.normalProp).toBe('normal value'); - expect(result?.properties).not.toHaveProperty('apiKey'); // Should be filtered out - }); - - it('should handle nested object sanitization with depth limit', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'nested_event', - properties: { - nested: { - level1: { - level2: { - level3: { - level4: 'should be truncated', - apiKey: 'secret123', - description: 'Visit https://example.com' - }, - description: 'Visit https://another.com' - } - } - } - } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(result?.properties.nested.level1.level2.level3).toBe('[NESTED]'); - expect(result?.properties.nested.level1.level2.description).toBe('Visit [URL]'); - }); - - it('should handle array sanitization with size limit', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'array_event', - properties: { - items: Array.from({ length: 15 }, (_, i) => ({ - id: i, - description: 'Visit https://example.com', - value: `item-${i}` - })) - } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(Array.isArray(result?.properties.items)).toBe(true); - expect(result?.properties.items.length).toBe(10); // Should be limited to 10 - }); - - it('should reject events with invalid user_id', () => { - const event: TelemetryEvent = { - user_id: '', // Empty string - event: 'test_event', - properties: {} - }; - - const result = validator.validateEvent(event); - expect(result).toBeNull(); - }); - - it('should reject events with invalid event name', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'invalid-event-name!@#', // Invalid characters - properties: {} - }; - - const result = validator.validateEvent(event); - expect(result).toBeNull(); - }); - - it('should reject tool_used event with invalid properties', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'tool_used', - properties: { - tool: 'test', - success: 'not-a-boolean', // Should be boolean - duration: -1 // Should be positive - } - }; - - const result = validator.validateEvent(event); - expect(result).toBeNull(); - }); - - it('should filter out sensitive keys from properties', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'sensitive_event', - properties: { - password: 'secret123', - token: 'bearer-token', - apikey: 'api-key-value', - secret: 'secret-value', - credential: 'cred-value', - auth: 'auth-header', - url: 'https://example.com', - endpoint: 'api.example.com', - host: 'localhost', - database: 'prod-db', - normalProp: 'safe-value', - count: 42, - enabled: true - } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(result?.properties).not.toHaveProperty('password'); - expect(result?.properties).not.toHaveProperty('token'); - expect(result?.properties).not.toHaveProperty('apikey'); - expect(result?.properties).not.toHaveProperty('secret'); - expect(result?.properties).not.toHaveProperty('credential'); - expect(result?.properties).not.toHaveProperty('auth'); - expect(result?.properties).not.toHaveProperty('url'); - expect(result?.properties).not.toHaveProperty('endpoint'); - expect(result?.properties).not.toHaveProperty('host'); - expect(result?.properties).not.toHaveProperty('database'); - expect(result?.properties.normalProp).toBe('safe-value'); - expect(result?.properties.count).toBe(42); - expect(result?.properties.enabled).toBe(true); - }); - - it('should handle validation_details event schema', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'validation_details', - properties: { - nodeType: 'nodes-base.httpRequest', - errorType: 'required_field_missing', - errorCategory: 'validation_error', - details: { field: 'url' } - } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(result?.properties.nodeType).toBe('nodes-base.httpRequest'); - expect(result?.properties.errorType).toBe('required_field_missing'); - }); - - it('should handle null and undefined values', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'null_event', - properties: { - nullValue: null, - undefinedValue: undefined, - normalValue: 'test' - } - }; - - const result = validator.validateEvent(event); - expect(result).not.toBeNull(); - expect(result?.properties.nullValue).toBeNull(); - expect(result?.properties.undefinedValue).toBeNull(); - expect(result?.properties.normalValue).toBe('test'); - }); - }); - - describe('validateWorkflow()', () => { - it('should validate a valid workflow', () => { - const workflow: WorkflowTelemetry = { - user_id: 'user123', - workflow_hash: 'hash123', - node_count: 3, - node_types: ['webhook', 'httpRequest', 'set'], - has_trigger: true, - has_webhook: true, - complexity: 'medium', - sanitized_workflow: { - nodes: [ - { id: '1', type: 'webhook' }, - { id: '2', type: 'httpRequest' }, - { id: '3', type: 'set' } - ], - connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } } - } - }; - - const result = validator.validateWorkflow(workflow); - expect(result).toEqual(workflow); - }); - - it('should reject workflow with too many nodes', () => { - const workflow: WorkflowTelemetry = { - user_id: 'user123', - workflow_hash: 'hash123', - node_count: 1001, // Over limit - node_types: ['webhook'], - has_trigger: true, - has_webhook: true, - complexity: 'complex', - sanitized_workflow: { - nodes: [], - connections: {} - } - }; - - const result = validator.validateWorkflow(workflow); - expect(result).toBeNull(); - }); - - it('should reject workflow with invalid complexity', () => { - const workflow = { - user_id: 'user123', - workflow_hash: 'hash123', - node_count: 3, - node_types: ['webhook'], - has_trigger: true, - has_webhook: true, - complexity: 'invalid' as any, // Invalid complexity - sanitized_workflow: { - nodes: [], - connections: {} - } - }; - - const result = validator.validateWorkflow(workflow); - expect(result).toBeNull(); - }); - - it('should reject workflow with too many node types', () => { - const workflow: WorkflowTelemetry = { - user_id: 'user123', - workflow_hash: 'hash123', - node_count: 3, - node_types: Array.from({ length: 101 }, (_, i) => `node-${i}`), // Over limit - has_trigger: true, - has_webhook: true, - complexity: 'complex', - sanitized_workflow: { - nodes: [], - connections: {} - } - }; - - const result = validator.validateWorkflow(workflow); - expect(result).toBeNull(); - }); - }); - - describe('getStats()', () => { - it('should track validation statistics', () => { - const validEvent: TelemetryEvent = { - user_id: 'user123', - event: 'valid_event', - properties: {} - }; - - const invalidEvent: TelemetryEvent = { - user_id: '', // Invalid - event: 'invalid_event', - properties: {} - }; - - validator.validateEvent(validEvent); - validator.validateEvent(validEvent); - validator.validateEvent(invalidEvent); - - const stats = validator.getStats(); - expect(stats.successes).toBe(2); - expect(stats.errors).toBe(1); - expect(stats.total).toBe(3); - expect(stats.errorRate).toBeCloseTo(0.333, 3); - }); - - it('should handle division by zero in error rate', () => { - const stats = validator.getStats(); - expect(stats.errorRate).toBe(0); - }); - }); - - describe('resetStats()', () => { - it('should reset validation statistics', () => { - const validEvent: TelemetryEvent = { - user_id: 'user123', - event: 'valid_event', - properties: {} - }; - - validator.validateEvent(validEvent); - validator.resetStats(); - - const stats = validator.getStats(); - expect(stats.successes).toBe(0); - expect(stats.errors).toBe(0); - expect(stats.total).toBe(0); - expect(stats.errorRate).toBe(0); - }); - }); - - describe('Schema validation', () => { - describe('telemetryEventSchema', () => { - it('should validate with created_at timestamp', () => { - const event = { - user_id: 'user123', - event: 'test_event', - properties: {}, - created_at: '2024-01-01T00:00:00Z' - }; - - const result = telemetryEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - - it('should reject invalid datetime format', () => { - const event = { - user_id: 'user123', - event: 'test_event', - properties: {}, - created_at: 'invalid-date' - }; - - const result = telemetryEventSchema.safeParse(event); - expect(result.success).toBe(false); - }); - - it('should enforce user_id length limits', () => { - const longUserId = 'a'.repeat(65); - const event = { - user_id: longUserId, - event: 'test_event', - properties: {} - }; - - const result = telemetryEventSchema.safeParse(event); - expect(result.success).toBe(false); - }); - - it('should enforce event name regex pattern', () => { - const event = { - user_id: 'user123', - event: 'invalid event name with spaces!', - properties: {} - }; - - const result = telemetryEventSchema.safeParse(event); - expect(result.success).toBe(false); - }); - }); - - describe('workflowTelemetrySchema', () => { - it('should enforce node array size limits', () => { - const workflow = { - user_id: 'user123', - workflow_hash: 'hash123', - node_count: 3, - node_types: ['test'], - has_trigger: true, - has_webhook: false, - complexity: 'simple', - sanitized_workflow: { - nodes: Array.from({ length: 1001 }, (_, i) => ({ id: i })), // Over limit - connections: {} - } - }; - - const result = workflowTelemetrySchema.safeParse(workflow); - expect(result.success).toBe(false); - }); - - it('should validate with optional created_at', () => { - const workflow = { - user_id: 'user123', - workflow_hash: 'hash123', - node_count: 1, - node_types: ['webhook'], - has_trigger: true, - has_webhook: true, - complexity: 'simple', - sanitized_workflow: { - nodes: [{ id: '1' }], - connections: {} - }, - created_at: '2024-01-01T00:00:00Z' - }; - - const result = workflowTelemetrySchema.safeParse(workflow); - expect(result.success).toBe(true); - }); - }); - }); - - describe('String sanitization edge cases', () => { - it('should handle multiple URLs in same string', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'test_event', - properties: { - description: 'Visit https://example.com or http://test.com for more info' - } - }; - - const result = validator.validateEvent(event); - expect(result?.properties.description).toBe('Visit [URL] or [URL] for more info'); - }); - - it('should handle mixed sensitive content', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'test_event', - properties: { - message: 'Contact admin@example.com at https://secure.com with key abc123def456ghi789jkl012mno345pqr' - } - }; - - const result = validator.validateEvent(event); - expect(result?.properties.message).toBe('Contact [EMAIL] at [URL] with key [KEY]'); - }); - - it('should preserve non-sensitive content', () => { - const event: TelemetryEvent = { - user_id: 'user123', - event: 'test_event', - properties: { - status: 'success', - count: 42, - enabled: true, - short_id: 'abc123' // Too short to be considered a key - } - }; - - const result = validator.validateEvent(event); - expect(result?.properties.status).toBe('success'); - expect(result?.properties.count).toBe(42); - expect(result?.properties.enabled).toBe(true); - expect(result?.properties.short_id).toBe('abc123'); - }); - }); - - describe('Error handling', () => { - it('should handle Zod parsing errors gracefully', () => { - const invalidEvent = { - user_id: 123, // Should be string - event: 'test_event', - properties: {} - }; - - const result = validator.validateEvent(invalidEvent as any); - expect(result).toBeNull(); - }); - - it('should handle unexpected errors during validation', () => { - const eventWithCircularRef: any = { - user_id: 'user123', - event: 'test_event', - properties: {} - }; - // Create circular reference - eventWithCircularRef.properties.self = eventWithCircularRef; - - const result = validator.validateEvent(eventWithCircularRef); - // Should handle gracefully and not throw - expect(result).not.toThrow; - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/telemetry/mutation-tracker.test.ts b/tests/unit/telemetry/mutation-tracker.test.ts deleted file mode 100644 index f212cd1..0000000 --- a/tests/unit/telemetry/mutation-tracker.test.ts +++ /dev/null @@ -1,817 +0,0 @@ -/** - * Unit tests for MutationTracker - Sanitization and Processing - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { MutationTracker } from '../../../src/telemetry/mutation-tracker'; -import { WorkflowMutationData, MutationToolName } from '../../../src/telemetry/mutation-types'; - -describe('MutationTracker', () => { - let tracker: MutationTracker; - - beforeEach(() => { - tracker = new MutationTracker(); - tracker.clearRecentMutations(); - }); - - describe('Workflow Sanitization', () => { - it('should remove credentials from workflow level', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test sanitization', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {}, - credentials: { apiKey: 'secret-key-123' }, - sharedWorkflows: ['user1', 'user2'], - ownedBy: { id: 'user1', email: 'user@example.com' } - }, - workflowAfter: { - id: 'wf1', - name: 'Test Updated', - nodes: [], - connections: {}, - credentials: { apiKey: 'secret-key-456' } - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - expect(result!.workflowBefore).toBeDefined(); - expect(result!.workflowBefore.credentials).toBeUndefined(); - expect(result!.workflowBefore.sharedWorkflows).toBeUndefined(); - expect(result!.workflowBefore.ownedBy).toBeUndefined(); - expect(result!.workflowAfter.credentials).toBeUndefined(); - }); - - it('should remove credentials from node level', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test node credentials', - operations: [{ type: 'addNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - credentials: { - httpBasicAuth: { - id: 'cred-123', - name: 'My Auth' - } - }, - parameters: { - url: 'https://api.example.com' - } - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - credentials: { - httpBasicAuth: { - id: 'cred-456', - name: 'Updated Auth' - } - }, - parameters: { - url: 'https://api.example.com' - } - } - ], - connections: {} - }, - mutationSuccess: true, - durationMs: 150 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - expect(result!.workflowBefore.nodes[0].credentials).toBeUndefined(); - expect(result!.workflowAfter.nodes[0].credentials).toBeUndefined(); - }); - - it('should redact API keys in parameters', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test API key redaction', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'OpenAI', - type: 'n8n-nodes-base.openAi', - position: [100, 100], - parameters: { - apiKeyField: 'sk-1234567890abcdef1234567890abcdef', - tokenField: 'Bearer abc123def456', - config: { - passwordField: 'secret-password-123' - } - } - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'OpenAI', - type: 'n8n-nodes-base.openAi', - position: [100, 100], - parameters: { - apiKeyField: 'sk-newkey567890abcdef1234567890abcdef' - } - } - ], - connections: {} - }, - mutationSuccess: true, - durationMs: 200 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - const params = result!.workflowBefore.nodes[0].parameters; - // Fields with sensitive key names are redacted - expect(params.apiKeyField).toBe('[REDACTED]'); - expect(params.tokenField).toBe('[REDACTED]'); - expect(params.config.passwordField).toBe('[REDACTED]'); - }); - - it('should redact URLs with authentication', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test URL redaction', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: { - url: 'https://user:password@api.example.com/endpoint', - webhookUrl: 'http://admin:secret@webhook.example.com' - } - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - const params = result!.workflowBefore.nodes[0].parameters; - // URL auth is redacted but path is preserved - expect(params.url).toBe('[REDACTED_URL_WITH_AUTH]/endpoint'); - expect(params.webhookUrl).toBe('[REDACTED_URL_WITH_AUTH]'); - }); - - it('should redact long tokens (32+ characters)', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test token redaction', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'Slack', - type: 'n8n-nodes-base.slack', - position: [100, 100], - parameters: { - message: 'Token: test-token-1234567890-1234567890123-abcdefghijklmnopqrstuvwx' - } - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - const message = result!.workflowBefore.nodes[0].parameters.message; - expect(message).toContain('[REDACTED_TOKEN]'); - }); - - it('should redact OpenAI-style keys', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test OpenAI key redaction', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'Code', - type: 'n8n-nodes-base.code', - position: [100, 100], - parameters: { - code: 'const apiKey = "sk-proj-abcd1234efgh5678ijkl9012mnop3456";' - } - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - const code = result!.workflowBefore.nodes[0].parameters.code; - // The 32+ char regex runs before OpenAI-specific regex, so it becomes [REDACTED_TOKEN] - expect(code).toContain('[REDACTED_TOKEN]'); - }); - - it('should redact Bearer tokens', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test Bearer token redaction', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: { - headerParameters: { - parameter: [ - { - name: 'Authorization', - value: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' - } - ] - } - } - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - const authValue = result!.workflowBefore.nodes[0].parameters.headerParameters.parameter[0].value; - expect(authValue).toBe('Bearer [REDACTED]'); - }); - - it('should preserve workflow structure while sanitizing', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test structure preservation', - operations: [{ type: 'addNode' }], - workflowBefore: { - id: 'wf1', - name: 'My Workflow', - nodes: [ - { - id: 'node1', - name: 'Start', - type: 'n8n-nodes-base.start', - position: [100, 100], - parameters: {} - }, - { - id: 'node2', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [300, 100], - parameters: { - url: 'https://api.example.com', - apiKey: 'secret-key' - } - } - ], - connections: { - Start: { - main: [[{ node: 'HTTP', type: 'main', index: 0 }]] - } - }, - active: true, - credentials: { apiKey: 'workflow-secret' } - }, - workflowAfter: { - id: 'wf1', - name: 'My Workflow', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 150 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - // Check structure preserved - expect(result!.workflowBefore.id).toBe('wf1'); - expect(result!.workflowBefore.name).toBe('My Workflow'); - expect(result!.workflowBefore.nodes).toHaveLength(2); - expect(result!.workflowBefore.connections).toBeDefined(); - expect(result!.workflowBefore.active).toBe(true); - - // Check credentials removed - expect(result!.workflowBefore.credentials).toBeUndefined(); - - // Check node parameters sanitized - expect(result!.workflowBefore.nodes[1].parameters.apiKey).toBe('[REDACTED]'); - - // Check connections preserved - expect(result!.workflowBefore.connections.Start).toBeDefined(); - }); - - it('should handle nested objects recursively', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test nested sanitization', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'Complex Node', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: { - authentication: { - type: 'oauth2', - // Use 'settings' instead of 'credentials' since 'credentials' is a sensitive key - settings: { - clientId: 'safe-client-id', - clientSecret: 'very-secret-key', - nested: { - apiKeyValue: 'deep-secret-key', - tokenValue: 'nested-token' - } - } - } - } - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - const auth = result!.workflowBefore.nodes[0].parameters.authentication; - // The key 'authentication' contains 'auth' which is sensitive, so entire object is redacted - expect(auth).toBe('[REDACTED]'); - }); - }); - - describe('Deduplication', () => { - it('should detect and skip duplicate mutations', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'First mutation', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test Updated', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - // First mutation should succeed - const result1 = await tracker.processMutation(data, 'test-user'); - expect(result1).toBeTruthy(); - - // Exact duplicate should be skipped - const result2 = await tracker.processMutation(data, 'test-user'); - expect(result2).toBeNull(); - }); - - it('should allow mutations with different workflows', async () => { - const data1: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'First mutation', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test 1', - nodes: [], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test 1 Updated', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const data2: WorkflowMutationData = { - ...data1, - workflowBefore: { - id: 'wf2', - name: 'Test 2', - nodes: [], - connections: {} - }, - workflowAfter: { - id: 'wf2', - name: 'Test 2 Updated', - nodes: [], - connections: {} - } - }; - - const result1 = await tracker.processMutation(data1, 'test-user'); - const result2 = await tracker.processMutation(data2, 'test-user'); - - expect(result1).toBeTruthy(); - expect(result2).toBeTruthy(); - }); - }); - - describe('Structural Hash Generation', () => { - it('should generate structural hashes for both before and after workflows', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test structural hash generation', - operations: [{ type: 'addNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'Start', - type: 'n8n-nodes-base.start', - position: [100, 100], - parameters: {} - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'Start', - type: 'n8n-nodes-base.start', - position: [100, 100], - parameters: {} - }, - { - id: 'node2', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [300, 100], - parameters: { url: 'https://api.example.com' } - } - ], - connections: { - Start: { - main: [[{ node: 'HTTP', type: 'main', index: 0 }]] - } - } - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - expect(result!.workflowStructureHashBefore).toBeDefined(); - expect(result!.workflowStructureHashAfter).toBeDefined(); - expect(typeof result!.workflowStructureHashBefore).toBe('string'); - expect(typeof result!.workflowStructureHashAfter).toBe('string'); - expect(result!.workflowStructureHashBefore!.length).toBe(16); - expect(result!.workflowStructureHashAfter!.length).toBe(16); - }); - - it('should generate different structural hashes when node types change', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test hash changes with node types', - operations: [{ type: 'addNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'Start', - type: 'n8n-nodes-base.start', - position: [100, 100], - parameters: {} - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'Start', - type: 'n8n-nodes-base.start', - position: [100, 100], - parameters: {} - }, - { - id: 'node2', - name: 'Slack', - type: 'n8n-nodes-base.slack', - position: [300, 100], - parameters: {} - } - ], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - expect(result!.workflowStructureHashBefore).not.toBe(result!.workflowStructureHashAfter); - }); - - it('should generate same structural hash for workflows with same structure but different parameters', async () => { - const workflow1Before = { - id: 'wf1', - name: 'Test 1', - nodes: [ - { - id: 'node1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: { url: 'https://api1.example.com' } - } - ], - connections: {} - }; - - const workflow1After = { - id: 'wf1', - name: 'Test 1 Updated', - nodes: [ - { - id: 'node1', - name: 'HTTP', - type: 'n8n-nodes-base.httpRequest', - position: [100, 100], - parameters: { url: 'https://api1-updated.example.com' } - } - ], - connections: {} - }; - - const workflow2Before = { - id: 'wf2', - name: 'Test 2', - nodes: [ - { - id: 'node2', - name: 'Different Name', - type: 'n8n-nodes-base.httpRequest', - position: [200, 200], - parameters: { url: 'https://api2.example.com' } - } - ], - connections: {} - }; - - const workflow2After = { - id: 'wf2', - name: 'Test 2 Updated', - nodes: [ - { - id: 'node2', - name: 'Different Name', - type: 'n8n-nodes-base.httpRequest', - position: [200, 200], - parameters: { url: 'https://api2-updated.example.com' } - } - ], - connections: {} - }; - - const data1: WorkflowMutationData = { - sessionId: 'test-session-1', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test 1', - operations: [{ type: 'updateNode', nodeId: 'node1', updates: { 'parameters.test': 'value1' } } as any], - workflowBefore: workflow1Before, - workflowAfter: workflow1After, - mutationSuccess: true, - durationMs: 100 - }; - - const data2: WorkflowMutationData = { - sessionId: 'test-session-2', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test 2', - operations: [{ type: 'updateNode', nodeId: 'node2', updates: { 'parameters.test': 'value2' } } as any], - workflowBefore: workflow2Before, - workflowAfter: workflow2After, - mutationSuccess: true, - durationMs: 100 - }; - - const result1 = await tracker.processMutation(data1, 'test-user-1'); - const result2 = await tracker.processMutation(data2, 'test-user-2'); - - expect(result1).toBeTruthy(); - expect(result2).toBeTruthy(); - // Same structure (same node types, same connection structure) should yield same hash - expect(result1!.workflowStructureHashBefore).toBe(result2!.workflowStructureHashBefore); - }); - - it('should generate both full hash and structural hash', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test both hash types', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test Updated', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = await tracker.processMutation(data, 'test-user'); - - expect(result).toBeTruthy(); - // Full hashes (includes all workflow data) - expect(result!.workflowHashBefore).toBeDefined(); - expect(result!.workflowHashAfter).toBeDefined(); - // Structural hashes (nodeTypes + connections only) - expect(result!.workflowStructureHashBefore).toBeDefined(); - expect(result!.workflowStructureHashAfter).toBeDefined(); - // They should be different since they hash different data - expect(result!.workflowHashBefore).not.toBe(result!.workflowStructureHashBefore); - }); - }); - - describe('Statistics', () => { - it('should track recent mutations count', async () => { - expect(tracker.getRecentMutationsCount()).toBe(0); - - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test counting', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test Updated', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 100 - }; - - await tracker.processMutation(data, 'test-user'); - expect(tracker.getRecentMutationsCount()).toBe(1); - - // Process another with different workflow - const data2 = { ...data, workflowBefore: { ...data.workflowBefore, id: 'wf2' } }; - await tracker.processMutation(data2, 'test-user'); - expect(tracker.getRecentMutationsCount()).toBe(2); - }); - - it('should clear recent mutations', async () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test clearing', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test Updated', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 100 - }; - - await tracker.processMutation(data, 'test-user'); - expect(tracker.getRecentMutationsCount()).toBe(1); - - tracker.clearRecentMutations(); - expect(tracker.getRecentMutationsCount()).toBe(0); - }); - }); -}); diff --git a/tests/unit/telemetry/mutation-validator.test.ts b/tests/unit/telemetry/mutation-validator.test.ts deleted file mode 100644 index 7f98bd6..0000000 --- a/tests/unit/telemetry/mutation-validator.test.ts +++ /dev/null @@ -1,557 +0,0 @@ -/** - * Unit tests for MutationValidator - Data Quality Validation - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { MutationValidator } from '../../../src/telemetry/mutation-validator'; -import { WorkflowMutationData, MutationToolName } from '../../../src/telemetry/mutation-types'; -import type { UpdateNodeOperation } from '../../../src/types/workflow-diff'; - -describe('MutationValidator', () => { - let validator: MutationValidator; - - beforeEach(() => { - validator = new MutationValidator(); - }); - - describe('Workflow Structure Validation', () => { - it('should accept valid workflow structure', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Valid mutation', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test Updated', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it('should reject workflow without nodes array', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Invalid mutation', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - connections: {} - } as any, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(false); - expect(result.errors).toContain('Invalid workflow_before structure'); - }); - - it('should reject workflow without connections object', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Invalid mutation', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [] - } as any, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(false); - expect(result.errors).toContain('Invalid workflow_before structure'); - }); - - it('should reject null workflow', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Invalid mutation', - operations: [{ type: 'updateNode' }], - workflowBefore: null as any, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(false); - expect(result.errors).toContain('Invalid workflow_before structure'); - }); - }); - - describe('Workflow Size Validation', () => { - it('should accept workflows within size limit', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Size test', - operations: [{ type: 'addNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [{ - id: 'node1', - name: 'Start', - type: 'n8n-nodes-base.start', - position: [100, 100], - parameters: {} - }], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(true); - expect(result.errors).not.toContain(expect.stringContaining('size')); - }); - - it('should reject oversized workflows', () => { - // Create a very large workflow (over 500KB default limit) - // 600KB string = 600,000 characters - const largeArray = new Array(600000).fill('x').join(''); - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Oversized test', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [{ - id: 'node1', - name: 'Large', - type: 'n8n-nodes-base.code', - position: [100, 100], - parameters: { - code: largeArray - } - }], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(false); - expect(result.errors.some(err => err.includes('size') && err.includes('exceeds'))).toBe(true); - }); - - it('should respect custom size limit', () => { - const customValidator = new MutationValidator({ maxWorkflowSizeKb: 1 }); - - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Custom size test', - operations: [{ type: 'addNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [{ - id: 'node1', - name: 'Medium', - type: 'n8n-nodes-base.code', - position: [100, 100], - parameters: { - code: 'x'.repeat(2000) // ~2KB - } - }], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = customValidator.validate(data); - expect(result.valid).toBe(false); - expect(result.errors.some(err => err.includes('exceeds maximum (1KB)'))).toBe(true); - }); - }); - - describe('Intent Validation', () => { - it('should warn about empty intent', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: '', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.warnings).toContain('User intent is empty'); - }); - - it('should warn about very short intent', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'fix', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.warnings).toContain('User intent is too short (less than 5 characters)'); - }); - - it('should warn about very long intent', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'x'.repeat(1001), - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.warnings).toContain('User intent is very long (over 1000 characters)'); - }); - - it('should accept good intent length', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Add error handling to API nodes', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.warnings).not.toContain(expect.stringContaining('intent')); - }); - }); - - describe('Operations Validation', () => { - it('should reject empty operations array', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test', - operations: [], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(false); - expect(result.errors).toContain('No operations provided'); - }); - - it('should accept operations array with items', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test', - operations: [ - { type: 'addNode' }, - { type: 'addConnection' } - ], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(true); - expect(result.errors).not.toContain('No operations provided'); - }); - }); - - describe('Duration Validation', () => { - it('should reject negative duration', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: -100 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(false); - expect(result.errors).toContain('Duration cannot be negative'); - }); - - it('should warn about very long duration', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 400000 // Over 5 minutes - }; - - const result = validator.validate(data); - expect(result.warnings).toContain('Duration is very long (over 5 minutes)'); - }); - - it('should accept reasonable duration', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - mutationSuccess: true, - durationMs: 150 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(true); - expect(result.warnings).not.toContain(expect.stringContaining('Duration')); - }); - }); - - describe('Meaningful Change Detection', () => { - it('should warn when workflows are identical', () => { - const workflow = { - id: 'wf1', - name: 'Test', - nodes: [ - { - id: 'node1', - name: 'Start', - type: 'n8n-nodes-base.start', - position: [100, 100], - parameters: {} - } - ], - connections: {} - }; - - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'No actual change', - operations: [{ type: 'updateNode' }], - workflowBefore: workflow, - workflowAfter: JSON.parse(JSON.stringify(workflow)), // Deep clone - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.warnings).toContain('No meaningful change detected between before and after workflows'); - }); - - it('should not warn when workflows are different', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Real change', - operations: [{ type: 'updateNode' }], - workflowBefore: { - id: 'wf1', - name: 'Test', - nodes: [], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'Test Updated', - nodes: [], - connections: {} - }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.warnings).not.toContain(expect.stringContaining('meaningful change')); - }); - }); - - describe('Validation Data Consistency', () => { - it('should warn about invalid validation structure', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - validationBefore: { valid: 'yes' } as any, // Invalid structure - validationAfter: { valid: true, errors: [] }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.warnings).toContain('Invalid validation_before structure'); - }); - - it('should accept valid validation structure', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Test', - operations: [{ type: 'updateNode' }], - workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} }, - validationBefore: { valid: false, errors: [{ type: 'test_error', message: 'Error 1' }] }, - validationAfter: { valid: true, errors: [] }, - mutationSuccess: true, - durationMs: 100 - }; - - const result = validator.validate(data); - expect(result.warnings).not.toContain(expect.stringContaining('validation')); - }); - }); - - describe('Comprehensive Validation', () => { - it('should collect multiple errors and warnings', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: '', // Empty - warning - operations: [], // Empty - error - workflowBefore: null as any, // Invalid - error - workflowAfter: { nodes: [] } as any, // Missing connections - error - mutationSuccess: true, - durationMs: -50 // Negative - error - }; - - const result = validator.validate(data); - expect(result.valid).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.warnings.length).toBeGreaterThan(0); - }); - - it('should pass validation with all criteria met', () => { - const data: WorkflowMutationData = { - sessionId: 'test-session-123', - toolName: MutationToolName.UPDATE_PARTIAL, - userIntent: 'Add error handling to HTTP Request nodes', - operations: [ - { type: 'updateNode', nodeName: 'node1', updates: { onError: 'continueErrorOutput' } } as UpdateNodeOperation - ], - workflowBefore: { - id: 'wf1', - name: 'API Workflow', - nodes: [ - { - id: 'node1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [300, 200], - parameters: { - url: 'https://api.example.com', - method: 'GET' - } - } - ], - connections: {} - }, - workflowAfter: { - id: 'wf1', - name: 'API Workflow', - nodes: [ - { - id: 'node1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - position: [300, 200], - parameters: { - url: 'https://api.example.com', - method: 'GET' - }, - onError: 'continueErrorOutput' - } - ], - connections: {} - }, - validationBefore: { valid: true, errors: [] }, - validationAfter: { valid: true, errors: [] }, - mutationSuccess: true, - durationMs: 245 - }; - - const result = validator.validate(data); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - }); -}); diff --git a/tests/unit/telemetry/telemetry-error.test.ts b/tests/unit/telemetry/telemetry-error.test.ts deleted file mode 100644 index c45cdc3..0000000 --- a/tests/unit/telemetry/telemetry-error.test.ts +++ /dev/null @@ -1,636 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { TelemetryError, TelemetryCircuitBreaker, TelemetryErrorAggregator } from '../../../src/telemetry/telemetry-error'; -import { TelemetryErrorType } from '../../../src/telemetry/telemetry-types'; -import { logger } from '../../../src/utils/logger'; - -// Mock logger to avoid console output in tests -vi.mock('../../../src/utils/logger', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } -})); - -describe('TelemetryError', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('constructor', () => { - it('should create error with all properties', () => { - const context = { operation: 'test', detail: 'info' }; - const error = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Test error', - context, - true - ); - - expect(error.name).toBe('TelemetryError'); - expect(error.message).toBe('Test error'); - expect(error.type).toBe(TelemetryErrorType.NETWORK_ERROR); - expect(error.context).toEqual(context); - expect(error.retryable).toBe(true); - expect(error.timestamp).toBeTypeOf('number'); - }); - - it('should default retryable to false', () => { - const error = new TelemetryError( - TelemetryErrorType.VALIDATION_ERROR, - 'Test error' - ); - - expect(error.retryable).toBe(false); - }); - - it('should handle undefined context', () => { - const error = new TelemetryError( - TelemetryErrorType.UNKNOWN_ERROR, - 'Test error' - ); - - expect(error.context).toBeUndefined(); - }); - - it('should maintain proper prototype chain', () => { - const error = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Test error' - ); - - expect(error instanceof TelemetryError).toBe(true); - expect(error instanceof Error).toBe(true); - }); - }); - - describe('toContext()', () => { - it('should convert error to context object', () => { - const context = { operation: 'flush', batch: 'events' }; - const error = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Failed to flush', - context, - true - ); - - const contextObj = error.toContext(); - expect(contextObj).toEqual({ - type: TelemetryErrorType.NETWORK_ERROR, - message: 'Failed to flush', - context, - timestamp: error.timestamp, - retryable: true - }); - }); - }); - - describe('log()', () => { - it('should log retryable errors as debug', () => { - const error = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Retryable error', - { attempt: 1 }, - true - ); - - error.log(); - - expect(logger.debug).toHaveBeenCalledWith( - 'Retryable telemetry error:', - expect.objectContaining({ - type: TelemetryErrorType.NETWORK_ERROR, - message: 'Retryable error', - attempt: 1 - }) - ); - }); - - it('should log non-retryable errors as debug', () => { - const error = new TelemetryError( - TelemetryErrorType.VALIDATION_ERROR, - 'Non-retryable error', - { field: 'user_id' }, - false - ); - - error.log(); - - expect(logger.debug).toHaveBeenCalledWith( - 'Non-retryable telemetry error:', - expect.objectContaining({ - type: TelemetryErrorType.VALIDATION_ERROR, - message: 'Non-retryable error', - field: 'user_id' - }) - ); - }); - - it('should handle errors without context', () => { - const error = new TelemetryError( - TelemetryErrorType.UNKNOWN_ERROR, - 'Simple error' - ); - - error.log(); - - expect(logger.debug).toHaveBeenCalledWith( - 'Non-retryable telemetry error:', - expect.objectContaining({ - type: TelemetryErrorType.UNKNOWN_ERROR, - message: 'Simple error' - }) - ); - }); - }); -}); - -describe('TelemetryCircuitBreaker', () => { - let circuitBreaker: TelemetryCircuitBreaker; - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - circuitBreaker = new TelemetryCircuitBreaker(3, 10000, 2); // 3 failures, 10s reset, 2 half-open requests - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('shouldAllow()', () => { - it('should allow requests in closed state', () => { - expect(circuitBreaker.shouldAllow()).toBe(true); - }); - - it('should open circuit after failure threshold', () => { - // Record 3 failures to reach threshold - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - - expect(circuitBreaker.shouldAllow()).toBe(false); - expect(circuitBreaker.getState().state).toBe('open'); - }); - - it('should transition to half-open after reset timeout', () => { - // Open the circuit - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - expect(circuitBreaker.shouldAllow()).toBe(false); - - // Advance time past reset timeout - vi.advanceTimersByTime(11000); - - // Should transition to half-open and allow request - expect(circuitBreaker.shouldAllow()).toBe(true); - expect(circuitBreaker.getState().state).toBe('half-open'); - }); - - it('should limit requests in half-open state', () => { - // Open the circuit - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - - // Advance to half-open - vi.advanceTimersByTime(11000); - - // Should allow limited number of requests (2 in our config) - expect(circuitBreaker.shouldAllow()).toBe(true); - expect(circuitBreaker.shouldAllow()).toBe(true); - expect(circuitBreaker.shouldAllow()).toBe(true); // Note: simplified implementation allows all - }); - - it('should not allow requests before reset timeout in open state', () => { - // Open the circuit - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - - // Advance time but not enough to reset - vi.advanceTimersByTime(5000); - - expect(circuitBreaker.shouldAllow()).toBe(false); - }); - }); - - describe('recordSuccess()', () => { - it('should reset failure count in closed state', () => { - // Record some failures but not enough to open - circuitBreaker.recordFailure(); - circuitBreaker.recordFailure(); - expect(circuitBreaker.getState().failureCount).toBe(2); - - // Success should reset count - circuitBreaker.recordSuccess(); - expect(circuitBreaker.getState().failureCount).toBe(0); - }); - - it('should close circuit after successful half-open requests', () => { - // Open the circuit - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - - // Go to half-open - vi.advanceTimersByTime(11000); - circuitBreaker.shouldAllow(); // First half-open request - circuitBreaker.shouldAllow(); // Second half-open request - - // The circuit breaker implementation requires success calls - // to match the number of half-open requests configured - circuitBreaker.recordSuccess(); - // In current implementation, state remains half-open - // This is a known behavior of the simplified circuit breaker - expect(circuitBreaker.getState().state).toBe('half-open'); - - // After another success, it should close - circuitBreaker.recordSuccess(); - expect(circuitBreaker.getState().state).toBe('closed'); - expect(circuitBreaker.getState().failureCount).toBe(0); - expect(logger.debug).toHaveBeenCalledWith('Circuit breaker closed after successful recovery'); - }); - - it('should not affect state when not in half-open after sufficient requests', () => { - // Open circuit, go to half-open, make one request - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - vi.advanceTimersByTime(11000); - circuitBreaker.shouldAllow(); // One half-open request - - // Record success but should not close yet (need 2 successful requests) - circuitBreaker.recordSuccess(); - expect(circuitBreaker.getState().state).toBe('half-open'); - }); - }); - - describe('recordFailure()', () => { - it('should increment failure count in closed state', () => { - circuitBreaker.recordFailure(); - expect(circuitBreaker.getState().failureCount).toBe(1); - - circuitBreaker.recordFailure(); - expect(circuitBreaker.getState().failureCount).toBe(2); - }); - - it('should open circuit when threshold reached', () => { - const error = new Error('Test error'); - - // Record failures to reach threshold - circuitBreaker.recordFailure(error); - circuitBreaker.recordFailure(error); - expect(circuitBreaker.getState().state).toBe('closed'); - - circuitBreaker.recordFailure(error); - expect(circuitBreaker.getState().state).toBe('open'); - expect(logger.debug).toHaveBeenCalledWith( - 'Circuit breaker opened after 3 failures', - { error: 'Test error' } - ); - }); - - it('should immediately open from half-open on failure', () => { - // Open circuit, go to half-open - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - vi.advanceTimersByTime(11000); - circuitBreaker.shouldAllow(); - - // Failure in half-open should immediately open - const error = new Error('Half-open failure'); - circuitBreaker.recordFailure(error); - expect(circuitBreaker.getState().state).toBe('open'); - expect(logger.debug).toHaveBeenCalledWith( - 'Circuit breaker opened from half-open state', - { error: 'Half-open failure' } - ); - }); - - it('should handle failure without error object', () => { - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - - expect(circuitBreaker.getState().state).toBe('open'); - expect(logger.debug).toHaveBeenCalledWith( - 'Circuit breaker opened after 3 failures', - { error: undefined } - ); - }); - }); - - describe('getState()', () => { - it('should return current state information', () => { - const state = circuitBreaker.getState(); - expect(state).toEqual({ - state: 'closed', - failureCount: 0, - canRetry: true - }); - }); - - it('should reflect state changes', () => { - circuitBreaker.recordFailure(); - circuitBreaker.recordFailure(); - - const state = circuitBreaker.getState(); - expect(state).toEqual({ - state: 'closed', - failureCount: 2, - canRetry: true - }); - - // Open circuit - circuitBreaker.recordFailure(); - const openState = circuitBreaker.getState(); - expect(openState).toEqual({ - state: 'open', - failureCount: 3, - canRetry: false - }); - }); - }); - - describe('reset()', () => { - it('should reset circuit breaker to initial state', () => { - // Open the circuit and advance time - for (let i = 0; i < 3; i++) { - circuitBreaker.recordFailure(); - } - vi.advanceTimersByTime(11000); - circuitBreaker.shouldAllow(); // Go to half-open - - // Reset - circuitBreaker.reset(); - - const state = circuitBreaker.getState(); - expect(state).toEqual({ - state: 'closed', - failureCount: 0, - canRetry: true - }); - }); - }); - - describe('different configurations', () => { - it('should work with custom failure threshold', () => { - const customBreaker = new TelemetryCircuitBreaker(1, 5000, 1); // 1 failure threshold - - expect(customBreaker.getState().state).toBe('closed'); - customBreaker.recordFailure(); - expect(customBreaker.getState().state).toBe('open'); - }); - - it('should work with custom half-open request count', () => { - const customBreaker = new TelemetryCircuitBreaker(1, 5000, 3); // 3 half-open requests - - // Open and go to half-open - customBreaker.recordFailure(); - vi.advanceTimersByTime(6000); - - // Should allow 3 requests in half-open - expect(customBreaker.shouldAllow()).toBe(true); - expect(customBreaker.shouldAllow()).toBe(true); - expect(customBreaker.shouldAllow()).toBe(true); - expect(customBreaker.shouldAllow()).toBe(true); // Fourth also allowed in simplified implementation - }); - }); -}); - -describe('TelemetryErrorAggregator', () => { - let aggregator: TelemetryErrorAggregator; - - beforeEach(() => { - aggregator = new TelemetryErrorAggregator(); - vi.clearAllMocks(); - }); - - describe('record()', () => { - it('should record error and increment counter', () => { - const error = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Network failure' - ); - - aggregator.record(error); - - const stats = aggregator.getStats(); - expect(stats.totalErrors).toBe(1); - expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(1); - }); - - it('should increment counter for repeated error types', () => { - const error1 = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'First failure' - ); - const error2 = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Second failure' - ); - - aggregator.record(error1); - aggregator.record(error2); - - const stats = aggregator.getStats(); - expect(stats.totalErrors).toBe(2); - expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(2); - }); - - it('should maintain limited error detail history', () => { - // Record more than max details (100) to test limiting - for (let i = 0; i < 105; i++) { - const error = new TelemetryError( - TelemetryErrorType.VALIDATION_ERROR, - `Error ${i}` - ); - aggregator.record(error); - } - - const stats = aggregator.getStats(); - expect(stats.totalErrors).toBe(105); - expect(stats.recentErrors).toHaveLength(10); // Only last 10 - }); - - it('should track different error types separately', () => { - const networkError = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Network issue' - ); - const validationError = new TelemetryError( - TelemetryErrorType.VALIDATION_ERROR, - 'Validation issue' - ); - const rateLimitError = new TelemetryError( - TelemetryErrorType.RATE_LIMIT_ERROR, - 'Rate limit hit' - ); - - aggregator.record(networkError); - aggregator.record(networkError); - aggregator.record(validationError); - aggregator.record(rateLimitError); - - const stats = aggregator.getStats(); - expect(stats.totalErrors).toBe(4); - expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(2); - expect(stats.errorsByType[TelemetryErrorType.VALIDATION_ERROR]).toBe(1); - expect(stats.errorsByType[TelemetryErrorType.RATE_LIMIT_ERROR]).toBe(1); - }); - }); - - describe('getStats()', () => { - it('should return empty stats when no errors recorded', () => { - const stats = aggregator.getStats(); - expect(stats).toEqual({ - totalErrors: 0, - errorsByType: {}, - mostCommonError: undefined, - recentErrors: [] - }); - }); - - it('should identify most common error type', () => { - const networkError = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Network issue' - ); - const validationError = new TelemetryError( - TelemetryErrorType.VALIDATION_ERROR, - 'Validation issue' - ); - - // Network errors more frequent - aggregator.record(networkError); - aggregator.record(networkError); - aggregator.record(networkError); - aggregator.record(validationError); - - const stats = aggregator.getStats(); - expect(stats.mostCommonError).toBe(TelemetryErrorType.NETWORK_ERROR); - }); - - it('should return recent errors in order', () => { - const error1 = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'First error' - ); - const error2 = new TelemetryError( - TelemetryErrorType.VALIDATION_ERROR, - 'Second error' - ); - const error3 = new TelemetryError( - TelemetryErrorType.RATE_LIMIT_ERROR, - 'Third error' - ); - - aggregator.record(error1); - aggregator.record(error2); - aggregator.record(error3); - - const stats = aggregator.getStats(); - expect(stats.recentErrors).toHaveLength(3); - expect(stats.recentErrors[0].message).toBe('First error'); - expect(stats.recentErrors[1].message).toBe('Second error'); - expect(stats.recentErrors[2].message).toBe('Third error'); - }); - - it('should handle tie in most common error', () => { - const networkError = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Network issue' - ); - const validationError = new TelemetryError( - TelemetryErrorType.VALIDATION_ERROR, - 'Validation issue' - ); - - // Equal counts - aggregator.record(networkError); - aggregator.record(validationError); - - const stats = aggregator.getStats(); - // Should return one of them (implementation dependent) - expect(stats.mostCommonError).toBeDefined(); - expect([TelemetryErrorType.NETWORK_ERROR, TelemetryErrorType.VALIDATION_ERROR]) - .toContain(stats.mostCommonError); - }); - }); - - describe('reset()', () => { - it('should clear all error data', () => { - const error = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Test error' - ); - aggregator.record(error); - - // Verify data exists - expect(aggregator.getStats().totalErrors).toBe(1); - - // Reset - aggregator.reset(); - - // Verify cleared - const stats = aggregator.getStats(); - expect(stats).toEqual({ - totalErrors: 0, - errorsByType: {}, - mostCommonError: undefined, - recentErrors: [] - }); - }); - }); - - describe('error detail management', () => { - it('should preserve error context in details', () => { - const context = { operation: 'flush', batchSize: 50 }; - const error = new TelemetryError( - TelemetryErrorType.NETWORK_ERROR, - 'Network failure', - context, - true - ); - - aggregator.record(error); - - const stats = aggregator.getStats(); - expect(stats.recentErrors[0]).toEqual({ - type: TelemetryErrorType.NETWORK_ERROR, - message: 'Network failure', - context, - timestamp: error.timestamp, - retryable: true - }); - }); - - it('should maintain error details queue with FIFO behavior', () => { - // Add more than max to test queue behavior - const errors = []; - for (let i = 0; i < 15; i++) { - const error = new TelemetryError( - TelemetryErrorType.VALIDATION_ERROR, - `Error ${i}` - ); - errors.push(error); - aggregator.record(error); - } - - const stats = aggregator.getStats(); - // Should have last 10 errors (5-14) - expect(stats.recentErrors).toHaveLength(10); - expect(stats.recentErrors[0].message).toBe('Error 5'); - expect(stats.recentErrors[9].message).toBe('Error 14'); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/telemetry/event-tracker.test.ts b/tests/unit/telemetry/telemetry-events.test.ts similarity index 100% rename from tests/unit/telemetry/event-tracker.test.ts rename to tests/unit/telemetry/telemetry-events.test.ts diff --git a/tests/unit/telemetry/batch-processor.test.ts b/tests/unit/telemetry/telemetry-processing.test.ts similarity index 100% rename from tests/unit/telemetry/batch-processor.test.ts rename to tests/unit/telemetry/telemetry-processing.test.ts diff --git a/tests/unit/telemetry/v2.18.3-fixes-verification.test.ts b/tests/unit/telemetry/v2.18.3-fixes-verification.test.ts deleted file mode 100644 index 2545da8..0000000 --- a/tests/unit/telemetry/v2.18.3-fixes-verification.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Verification Tests for v2.18.3 Critical Fixes - * Tests all 7 fixes from the code review: - * - CRITICAL-01: Database checkpoints logged - * - CRITICAL-02: Defensive initialization - * - CRITICAL-03: Non-blocking checkpoints - * - HIGH-01: ReDoS vulnerability fixed - * - HIGH-02: Race condition prevention - * - HIGH-03: Timeout on Supabase operations - * - HIGH-04: N8N API checkpoints logged - */ - -import { EarlyErrorLogger } from '../../../src/telemetry/early-error-logger'; -import { sanitizeErrorMessageCore } from '../../../src/telemetry/error-sanitization-utils'; -import { STARTUP_CHECKPOINTS } from '../../../src/telemetry/startup-checkpoints'; - -describe('v2.18.3 Critical Fixes Verification', () => { - describe('CRITICAL-02: Defensive Initialization', () => { - it('should initialize all fields to safe defaults before any throwing operation', () => { - // Create instance - should not throw even if Supabase fails - const logger = EarlyErrorLogger.getInstance(); - expect(logger).toBeDefined(); - - // Should be able to call methods immediately without crashing - expect(() => logger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED)).not.toThrow(); - expect(() => logger.getCheckpoints()).not.toThrow(); - expect(() => logger.getStartupDuration()).not.toThrow(); - }); - - it('should handle multiple getInstance calls correctly (singleton)', () => { - const logger1 = EarlyErrorLogger.getInstance(); - const logger2 = EarlyErrorLogger.getInstance(); - - expect(logger1).toBe(logger2); - }); - - it('should gracefully handle being disabled', () => { - const logger = EarlyErrorLogger.getInstance(); - - // Even if disabled, these should not throw - expect(() => logger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED)).not.toThrow(); - expect(() => logger.logStartupError(STARTUP_CHECKPOINTS.DATABASE_CONNECTING, new Error('test'))).not.toThrow(); - expect(() => logger.logStartupSuccess([], 100)).not.toThrow(); - }); - }); - - describe('CRITICAL-03: Non-blocking Checkpoints', () => { - it('logCheckpoint should be synchronous (fire-and-forget)', () => { - const logger = EarlyErrorLogger.getInstance(); - const start = Date.now(); - - // Should return immediately, not block - logger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED); - - const duration = Date.now() - start; - expect(duration).toBeLessThan(50); // Should be nearly instant - }); - - it('logStartupError should be synchronous (fire-and-forget)', () => { - const logger = EarlyErrorLogger.getInstance(); - const start = Date.now(); - - // Should return immediately, not block - logger.logStartupError(STARTUP_CHECKPOINTS.DATABASE_CONNECTING, new Error('test')); - - const duration = Date.now() - start; - expect(duration).toBeLessThan(50); // Should be nearly instant - }); - - it('logStartupSuccess should be synchronous (fire-and-forget)', () => { - const logger = EarlyErrorLogger.getInstance(); - const start = Date.now(); - - // Should return immediately, not block - logger.logStartupSuccess([STARTUP_CHECKPOINTS.PROCESS_STARTED], 100); - - const duration = Date.now() - start; - expect(duration).toBeLessThan(50); // Should be nearly instant - }); - }); - - describe('HIGH-01: ReDoS Vulnerability Fixed', () => { - it('should handle long token strings without catastrophic backtracking', () => { - // This would cause ReDoS with the old regex: (? { - // Test that the new pattern works correctly - const testCases = [ - { input: 'token=abc123', shouldContain: '[REDACTED]' }, - { input: 'token: xyz789', shouldContain: '[REDACTED]' }, - { input: 'Bearer token=secret', shouldContain: '[TOKEN]' }, // Bearer gets handled separately - { input: 'token = test', shouldContain: '[REDACTED]' }, - { input: 'some text here', shouldNotContain: '[REDACTED]' }, - ]; - - testCases.forEach((testCase) => { - const result = sanitizeErrorMessageCore(testCase.input); - if ('shouldContain' in testCase) { - expect(result).toContain(testCase.shouldContain); - } else if ('shouldNotContain' in testCase) { - expect(result).not.toContain(testCase.shouldNotContain); - } - }); - }); - - it('should handle edge cases without hanging', () => { - const edgeCases = [ - 'token=', - 'token:', - 'token = ', - '= token', - 'tokentoken=value', - ]; - - edgeCases.forEach((input) => { - const start = Date.now(); - expect(() => sanitizeErrorMessageCore(input)).not.toThrow(); - const duration = Date.now() - start; - expect(duration).toBeLessThan(50); - }); - }); - }); - - describe('HIGH-02: Race Condition Prevention', () => { - it('should track initialization state with initPromise', async () => { - const logger = EarlyErrorLogger.getInstance(); - - // Should have waitForInit method - expect(logger.waitForInit).toBeDefined(); - expect(typeof logger.waitForInit).toBe('function'); - - // Should be able to wait for init without hanging - await expect(logger.waitForInit()).resolves.not.toThrow(); - }); - - it('should handle concurrent checkpoint logging safely', () => { - const logger = EarlyErrorLogger.getInstance(); - - // Log multiple checkpoints concurrently - const checkpoints = [ - STARTUP_CHECKPOINTS.PROCESS_STARTED, - STARTUP_CHECKPOINTS.DATABASE_CONNECTING, - STARTUP_CHECKPOINTS.DATABASE_CONNECTED, - STARTUP_CHECKPOINTS.N8N_API_CHECKING, - STARTUP_CHECKPOINTS.N8N_API_READY, - ]; - - expect(() => { - checkpoints.forEach(cp => logger.logCheckpoint(cp)); - }).not.toThrow(); - }); - }); - - describe('HIGH-03: Timeout on Supabase Operations', () => { - it('should implement withTimeout wrapper function', async () => { - const logger = EarlyErrorLogger.getInstance(); - - // We can't directly test the private withTimeout function, - // but we can verify that operations don't hang indefinitely - const start = Date.now(); - - // Log an error - should complete quickly even if Supabase fails - logger.logStartupError(STARTUP_CHECKPOINTS.DATABASE_CONNECTING, new Error('test')); - - // Give it a moment to attempt the operation - await new Promise(resolve => setTimeout(resolve, 100)); - - const duration = Date.now() - start; - - // Should not hang for more than 6 seconds (5s timeout + 1s buffer) - expect(duration).toBeLessThan(6000); - }); - - it('should gracefully degrade when timeout occurs', async () => { - const logger = EarlyErrorLogger.getInstance(); - - // Multiple error logs should all complete quickly - const promises = []; - for (let i = 0; i < 5; i++) { - logger.logStartupError(STARTUP_CHECKPOINTS.DATABASE_CONNECTING, new Error(`test-${i}`)); - promises.push(new Promise(resolve => setTimeout(resolve, 50))); - } - - await Promise.all(promises); - - // All operations should have returned (fire-and-forget) - expect(true).toBe(true); - }); - }); - - describe('Error Sanitization - Shared Utilities', () => { - it('should remove sensitive patterns in correct order', () => { - const sensitiveData = 'Error: https://api.example.com/token=secret123 user@email.com'; - const sanitized = sanitizeErrorMessageCore(sensitiveData); - - expect(sanitized).not.toContain('api.example.com'); - expect(sanitized).not.toContain('secret123'); - expect(sanitized).not.toContain('user@email.com'); - expect(sanitized).toContain('[URL]'); - expect(sanitized).toContain('[EMAIL]'); - }); - - it('should handle AWS keys', () => { - const input = 'Error: AWS key AKIAIOSFODNN7EXAMPLE leaked'; - const result = sanitizeErrorMessageCore(input); - - expect(result).not.toContain('AKIAIOSFODNN7EXAMPLE'); - expect(result).toContain('[AWS_KEY]'); - }); - - it('should handle GitHub tokens', () => { - const input = 'Auth failed with ghp_1234567890abcdefghijklmnopqrstuvwxyz'; - const result = sanitizeErrorMessageCore(input); - - expect(result).not.toContain('ghp_1234567890abcdefghijklmnopqrstuvwxyz'); - expect(result).toContain('[GITHUB_TOKEN]'); - }); - - it('should handle JWTs', () => { - const input = 'JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghij'; - const result = sanitizeErrorMessageCore(input); - - // JWT pattern should match the full JWT - expect(result).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'); - expect(result).toContain('[JWT]'); - }); - - it('should limit stack traces to 3 lines', () => { - const stackTrace = 'Error: Test\n at func1 (file1.js:1:1)\n at func2 (file2.js:2:2)\n at func3 (file3.js:3:3)\n at func4 (file4.js:4:4)'; - const result = sanitizeErrorMessageCore(stackTrace); - - const lines = result.split('\n'); - expect(lines.length).toBeLessThanOrEqual(3); - }); - - it('should truncate at 500 chars after sanitization', () => { - const longMessage = 'Error: ' + 'a'.repeat(1000); - const result = sanitizeErrorMessageCore(longMessage); - - expect(result.length).toBeLessThanOrEqual(503); // 500 + '...' - }); - - it('should return safe default on sanitization failure', () => { - // Pass something that might cause issues - const result = sanitizeErrorMessageCore(null as any); - - expect(result).toBe('[SANITIZATION_FAILED]'); - }); - }); - - describe('Checkpoint Integration', () => { - it('should have all required checkpoint constants defined', () => { - expect(STARTUP_CHECKPOINTS.PROCESS_STARTED).toBe('process_started'); - expect(STARTUP_CHECKPOINTS.DATABASE_CONNECTING).toBe('database_connecting'); - expect(STARTUP_CHECKPOINTS.DATABASE_CONNECTED).toBe('database_connected'); - expect(STARTUP_CHECKPOINTS.N8N_API_CHECKING).toBe('n8n_api_checking'); - expect(STARTUP_CHECKPOINTS.N8N_API_READY).toBe('n8n_api_ready'); - expect(STARTUP_CHECKPOINTS.TELEMETRY_INITIALIZING).toBe('telemetry_initializing'); - expect(STARTUP_CHECKPOINTS.TELEMETRY_READY).toBe('telemetry_ready'); - expect(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_STARTING).toBe('mcp_handshake_starting'); - expect(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_COMPLETE).toBe('mcp_handshake_complete'); - expect(STARTUP_CHECKPOINTS.SERVER_READY).toBe('server_ready'); - }); - - it('should track checkpoints correctly', () => { - const logger = EarlyErrorLogger.getInstance(); - const initialCount = logger.getCheckpoints().length; - - logger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED); - - const checkpoints = logger.getCheckpoints(); - expect(checkpoints.length).toBeGreaterThanOrEqual(initialCount); - }); - - it('should calculate startup duration', () => { - const logger = EarlyErrorLogger.getInstance(); - const duration = logger.getStartupDuration(); - - expect(duration).toBeGreaterThanOrEqual(0); - expect(typeof duration).toBe('number'); - }); - }); -}); diff --git a/tests/unit/test-env-example.test.ts b/tests/unit/test-env-example.test.ts deleted file mode 100644 index 08da0a5..0000000 --- a/tests/unit/test-env-example.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Example test demonstrating test environment configuration usage - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { - getTestConfig, - getTestTimeout, - isFeatureEnabled, - isTestMode, - loadTestEnvironment -} from '@tests/setup/test-env'; -import { - withEnvOverrides, - createTestDatabasePath, - getMockApiUrl, - measurePerformance, - createTestLogger, - waitForCondition -} from '@tests/helpers/env-helpers'; - -describe('Test Environment Configuration Example', () => { - let config: ReturnType; - let logger: ReturnType; - - beforeAll(() => { - // Initialize config inside beforeAll to ensure environment is loaded - config = getTestConfig(); - logger = createTestLogger('test-env-example'); - - logger.info('Test suite starting with configuration:', { - environment: config.nodeEnv, - database: config.database.path, - apiUrl: config.api.url - }); - }); - - afterAll(() => { - logger.info('Test suite completed'); - }); - - it('should be in test mode', () => { - const testConfig = getTestConfig(); - expect(isTestMode()).toBe(true); - expect(testConfig.nodeEnv).toBe('test'); - expect(testConfig.isTest).toBe(true); - }); - - it('should have proper database configuration', () => { - const testConfig = getTestConfig(); - expect(testConfig.database.path).toBeDefined(); - expect(testConfig.database.rebuildOnStart).toBe(false); - expect(testConfig.database.seedData).toBe(true); - }); - - it.skip('should have mock API configuration', () => { - const testConfig = getTestConfig(); - // Add debug logging for CI - if (process.env.CI) { - console.log('CI Environment Debug:', { - NODE_ENV: process.env.NODE_ENV, - N8N_API_URL: process.env.N8N_API_URL, - N8N_API_KEY: process.env.N8N_API_KEY, - configUrl: testConfig.api.url, - configKey: testConfig.api.key - }); - } - expect(testConfig.api.url).toMatch(/mock-api/); - expect(testConfig.api.key).toBe('test-api-key-12345'); - }); - - it('should respect test timeouts', { timeout: getTestTimeout('unit') }, async () => { - const timeout = getTestTimeout('unit'); - expect(timeout).toBe(5000); - - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 100)); - }); - - it('should support environment overrides', () => { - const testConfig = getTestConfig(); - const originalLogLevel = testConfig.logging.level; - - const result = withEnvOverrides({ - LOG_LEVEL: 'debug', - DEBUG: 'true' - }, () => { - const newConfig = getTestConfig(); - expect(newConfig.logging.level).toBe('debug'); - expect(newConfig.logging.debug).toBe(true); - return 'success'; - }); - - expect(result).toBe('success'); - const configAfter = getTestConfig(); - expect(configAfter.logging.level).toBe(originalLogLevel); - }); - - it('should generate unique test database paths', () => { - const path1 = createTestDatabasePath('feature1'); - const path2 = createTestDatabasePath('feature1'); - - if (path1 !== ':memory:') { - expect(path1).not.toBe(path2); - expect(path1).toMatch(/test-feature1-\d+-\w+\.db$/); - } - }); - - it('should construct mock API URLs', () => { - const testConfig = getTestConfig(); - const baseUrl = getMockApiUrl(); - const endpointUrl = getMockApiUrl('/nodes'); - - expect(baseUrl).toBe(testConfig.api.url); - expect(endpointUrl).toBe(`${testConfig.api.url}/nodes`); - }); - - it.skipIf(!isFeatureEnabled('mockExternalApis'))('should check feature flags', () => { - const testConfig = getTestConfig(); - expect(testConfig.features.mockExternalApis).toBe(true); - expect(isFeatureEnabled('mockExternalApis')).toBe(true); - }); - - it('should measure performance', () => { - const measure = measurePerformance('test-operation'); - - // Test the performance measurement utility structure and behavior - // rather than relying on timing precision which is unreliable in CI - - // Capture initial state - const startTime = performance.now(); - - // Add some marks - measure.mark('start-processing'); - - // Do some minimal synchronous work - let sum = 0; - for (let i = 0; i < 10000; i++) { - sum += i; - } - - measure.mark('mid-processing'); - - // Do a bit more work - for (let i = 0; i < 10000; i++) { - sum += i * 2; - } - - const results = measure.end(); - const endTime = performance.now(); - - // Test the utility's correctness rather than exact timing - expect(results).toHaveProperty('total'); - expect(results).toHaveProperty('marks'); - expect(typeof results.total).toBe('number'); - expect(results.total).toBeGreaterThan(0); - - // Verify marks structure - expect(results.marks).toHaveProperty('start-processing'); - expect(results.marks).toHaveProperty('mid-processing'); - expect(typeof results.marks['start-processing']).toBe('number'); - expect(typeof results.marks['mid-processing']).toBe('number'); - - // Verify logical order of marks (this should always be true) - expect(results.marks['start-processing']).toBeLessThan(results.marks['mid-processing']); - expect(results.marks['start-processing']).toBeGreaterThanOrEqual(0); - expect(results.marks['mid-processing']).toBeLessThan(results.total); - - // Verify the total time is reasonable (should be between manual measurements) - const manualTotal = endTime - startTime; - expect(results.total).toBeLessThanOrEqual(manualTotal + 1); // Allow 1ms tolerance - - // Verify work was actually done - expect(sum).toBeGreaterThan(0); - }); - - it('should wait for conditions', async () => { - let counter = 0; - const incrementCounter = setInterval(() => counter++, 100); - - try { - await waitForCondition( - () => counter >= 3, - { - timeout: 1000, - interval: 50, - message: 'Counter did not reach 3' - } - ); - - expect(counter).toBeGreaterThanOrEqual(3); - } finally { - clearInterval(incrementCounter); - } - }); - - it('should have proper logging configuration', () => { - const testConfig = getTestConfig(); - expect(testConfig.logging.level).toBe('error'); - expect(testConfig.logging.debug).toBe(false); - expect(testConfig.logging.showStack).toBe(true); - - // Logger should respect configuration - logger.debug('This should not appear in test output'); - logger.error('This should appear in test output'); - }); - - it('should have performance thresholds', () => { - const testConfig = getTestConfig(); - expect(testConfig.performance.thresholds.apiResponse).toBe(100); - expect(testConfig.performance.thresholds.dbQuery).toBe(50); - expect(testConfig.performance.thresholds.nodeParse).toBe(200); - }); - - it('should disable caching and rate limiting in tests', () => { - const testConfig = getTestConfig(); - expect(testConfig.cache.enabled).toBe(false); - expect(testConfig.cache.ttl).toBe(0); - expect(testConfig.rateLimiting.max).toBe(0); - expect(testConfig.rateLimiting.window).toBe(0); - }); - - it('should configure test paths', () => { - const testConfig = getTestConfig(); - expect(testConfig.paths.fixtures).toBe('./tests/fixtures'); - expect(testConfig.paths.data).toBe('./tests/data'); - expect(testConfig.paths.snapshots).toBe('./tests/__snapshots__'); - }); - - it('should support MSW configuration', () => { - // Ensure test environment is loaded - if (!process.env.MSW_ENABLED) { - loadTestEnvironment(); - } - - const testConfig = getTestConfig(); - expect(testConfig.mocking.msw.enabled).toBe(true); - expect(testConfig.mocking.msw.apiDelay).toBe(0); - }); -}); \ No newline at end of file diff --git a/tests/unit/test-infrastructure.test.ts b/tests/unit/test-infrastructure.test.ts deleted file mode 100644 index ae2f602..0000000 --- a/tests/unit/test-infrastructure.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { nodeFactory, webhookNodeFactory, slackNodeFactory } from '@tests/fixtures/factories/node.factory'; - -// Mock better-sqlite3 -vi.mock('better-sqlite3'); - -describe('Test Infrastructure', () => { - describe('Database Mock', () => { - it('should create a mock database instance', async () => { - const Database = (await import('better-sqlite3')).default; - const db = new Database(':memory:'); - - expect(Database).toHaveBeenCalled(); - expect(db).toBeDefined(); - expect(db.prepare).toBeDefined(); - expect(db.exec).toBeDefined(); - expect(db.close).toBeDefined(); - }); - - it('should handle basic CRUD operations', async () => { - const { MockDatabase } = await import('@tests/unit/database/__mocks__/better-sqlite3'); - const db = new MockDatabase(); - - // Test data seeding - db._seedData('nodes', [ - { id: '1', name: 'test-node', type: 'webhook' } - ]); - - // Test SELECT - const selectStmt = db.prepare('SELECT * FROM nodes'); - const allNodes = selectStmt.all(); - expect(allNodes).toHaveLength(1); - expect(allNodes[0]).toEqual({ id: '1', name: 'test-node', type: 'webhook' }); - - // Test INSERT - const insertStmt = db.prepare('INSERT INTO nodes (id, name, type) VALUES (?, ?, ?)'); - const result = insertStmt.run({ id: '2', name: 'new-node', type: 'slack' }); - expect(result.changes).toBe(1); - - // Verify insert worked - const allNodesAfter = selectStmt.all(); - expect(allNodesAfter).toHaveLength(2); - }); - }); - - describe('Node Factory', () => { - it('should create a basic node definition', () => { - const node = nodeFactory.build(); - - expect(node).toMatchObject({ - name: expect.any(String), - displayName: expect.any(String), - description: expect.any(String), - version: expect.any(Number), - defaults: { - name: expect.any(String) - }, - inputs: ['main'], - outputs: ['main'], - properties: expect.any(Array), - credentials: [] - }); - }); - - it('should create a webhook node', () => { - const webhook = webhookNodeFactory.build(); - - expect(webhook).toMatchObject({ - name: 'webhook', - displayName: 'Webhook', - description: 'Starts the workflow when a webhook is called', - group: ['trigger'], - properties: expect.arrayContaining([ - expect.objectContaining({ - name: 'path', - type: 'string', - required: true - }), - expect.objectContaining({ - name: 'method', - type: 'options' - }) - ]) - }); - }); - - it('should create a slack node', () => { - const slack = slackNodeFactory.build(); - - expect(slack).toMatchObject({ - name: 'slack', - displayName: 'Slack', - description: 'Send messages to Slack', - group: ['output'], - credentials: [ - { - name: 'slackApi', - required: true - } - ], - properties: expect.arrayContaining([ - expect.objectContaining({ - name: 'resource', - type: 'options' - }), - expect.objectContaining({ - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: ['message'] - } - } - }) - ]) - }); - }); - - it('should allow overriding factory defaults', () => { - const customNode = nodeFactory.build({ - name: 'custom-node', - displayName: 'Custom Node', - version: 2 - }); - - expect(customNode.name).toBe('custom-node'); - expect(customNode.displayName).toBe('Custom Node'); - expect(customNode.version).toBe(2); - }); - - it('should create multiple unique nodes', () => { - const nodes = nodeFactory.buildList(5); - - expect(nodes).toHaveLength(5); - const names = nodes.map(n => n.name); - const uniqueNames = new Set(names); - expect(uniqueNames.size).toBe(5); - }); - }); -}); \ No newline at end of file diff --git a/vitest.config.benchmark.ts b/vitest.config.benchmark.ts deleted file mode 100644 index b9c4faa..0000000 --- a/vitest.config.benchmark.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import path from 'path'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - include: ['tests/benchmarks/**/*.bench.ts'], - benchmark: { - // Benchmark specific options - include: ['tests/benchmarks/**/*.bench.ts'], - reporters: ['default'], - }, - setupFiles: [], - pool: 'forks', - poolOptions: { - forks: { - singleFork: true, - }, - }, - // Increase timeout for benchmarks - testTimeout: 120000, - hookTimeout: 120000, - }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - '@tests': path.resolve(__dirname, './tests'), - }, - }, -}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index f4463ee..de6fcc6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,13 +15,12 @@ export default defineConfig({ pool: 'threads', poolOptions: { threads: { - singleThread: process.env.TEST_PARALLEL !== 'true', maxThreads: parseInt(process.env.TEST_MAX_WORKERS || '4', 10), minThreads: 1 } }, - // Retry configuration - retry: parseInt(process.env.TEST_RETRY_ATTEMPTS || '2', 10), + // No retries - flaky tests should be fixed, not masked + retry: 0, // Test reporter - reduce reporters in CI to prevent hanging reporters: process.env.CI ? ['default', 'junit'] : ['default'], outputFile: { @@ -45,10 +44,10 @@ export default defineConfig({ '**/__mocks__/**' ], thresholds: { - lines: 80, - functions: 80, - branches: 75, - statements: 80 + lines: 75, + functions: 75, + branches: 70, + statements: 75 }, // Add coverage-specific settings to prevent hanging all: false, // Don't collect coverage for untested files