This is page 25 of 60. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CI_TEST_INFRASTRUCTURE.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── skills.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-sanitizer.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── expression-utils.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── sqljs-memory-leak.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-sanitizer.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── expression-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /tests/unit/__mocks__/n8n-nodes-base.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { vi } from 'vitest'; 2 | 3 | // Mock types that match n8n-workflow 4 | interface INodeExecutionData { 5 | json: any; 6 | binary?: any; 7 | pairedItem?: any; 8 | } 9 | 10 | interface IExecuteFunctions { 11 | getInputData(): INodeExecutionData[]; 12 | getNodeParameter(parameterName: string, itemIndex: number, fallbackValue?: any): any; 13 | getCredentials(type: string): Promise<any>; 14 | helpers: { 15 | returnJsonArray(data: any): INodeExecutionData[]; 16 | httpRequest(options: any): Promise<any>; 17 | webhook(): any; 18 | }; 19 | } 20 | 21 | interface IWebhookFunctions { 22 | getWebhookName(): string; 23 | getBodyData(): any; 24 | getHeaderData(): any; 25 | getQueryData(): any; 26 | getRequestObject(): any; 27 | getResponseObject(): any; 28 | helpers: { 29 | returnJsonArray(data: any): INodeExecutionData[]; 30 | }; 31 | } 32 | 33 | interface INodeTypeDescription { 34 | displayName: string; 35 | name: string; 36 | group: string[]; 37 | version: number; 38 | description: string; 39 | defaults: { name: string }; 40 | inputs: string[]; 41 | outputs: string[]; 42 | credentials?: any[]; 43 | webhooks?: any[]; 44 | properties: any[]; 45 | icon?: string; 46 | subtitle?: string; 47 | } 48 | 49 | interface INodeType { 50 | description: INodeTypeDescription; 51 | execute?(this: IExecuteFunctions): Promise<INodeExecutionData[][]>; 52 | webhook?(this: IWebhookFunctions): Promise<any>; 53 | trigger?(this: any): Promise<void>; 54 | poll?(this: any): Promise<INodeExecutionData[][] | null>; 55 | } 56 | 57 | // Base mock node implementation 58 | class BaseMockNode implements INodeType { 59 | description: INodeTypeDescription; 60 | execute: any; 61 | webhook: any; 62 | 63 | constructor(description: INodeTypeDescription, execute?: any, webhook?: any) { 64 | this.description = description; 65 | this.execute = execute ? vi.fn(execute) : undefined; 66 | this.webhook = webhook ? vi.fn(webhook) : undefined; 67 | } 68 | } 69 | 70 | // Mock implementations for each node type 71 | const mockWebhookNode = new BaseMockNode( 72 | { 73 | displayName: 'Webhook', 74 | name: 'webhook', 75 | group: ['trigger'], 76 | version: 1, 77 | description: 'Starts the workflow when a webhook is called', 78 | defaults: { name: 'Webhook' }, 79 | inputs: [], 80 | outputs: ['main'], 81 | webhooks: [ 82 | { 83 | name: 'default', 84 | httpMethod: '={{$parameter["httpMethod"]}}', 85 | path: '={{$parameter["path"]}}', 86 | responseMode: '={{$parameter["responseMode"]}}', 87 | } 88 | ], 89 | properties: [ 90 | { 91 | displayName: 'Path', 92 | name: 'path', 93 | type: 'string', 94 | default: 'webhook', 95 | required: true, 96 | description: 'The path to listen on', 97 | }, 98 | { 99 | displayName: 'HTTP Method', 100 | name: 'httpMethod', 101 | type: 'options', 102 | default: 'GET', 103 | options: [ 104 | { name: 'GET', value: 'GET' }, 105 | { name: 'POST', value: 'POST' }, 106 | { name: 'PUT', value: 'PUT' }, 107 | { name: 'DELETE', value: 'DELETE' }, 108 | { name: 'HEAD', value: 'HEAD' }, 109 | { name: 'PATCH', value: 'PATCH' }, 110 | ], 111 | }, 112 | { 113 | displayName: 'Response Mode', 114 | name: 'responseMode', 115 | type: 'options', 116 | default: 'onReceived', 117 | options: [ 118 | { name: 'On Received', value: 'onReceived' }, 119 | { name: 'Last Node', value: 'lastNode' }, 120 | ], 121 | }, 122 | ], 123 | }, 124 | undefined, 125 | async function webhook(this: IWebhookFunctions) { 126 | const returnData: INodeExecutionData[] = []; 127 | returnData.push({ 128 | json: { 129 | headers: this.getHeaderData(), 130 | params: this.getQueryData(), 131 | body: this.getBodyData(), 132 | } 133 | }); 134 | return { 135 | workflowData: [returnData], 136 | }; 137 | } 138 | ); 139 | 140 | const mockHttpRequestNode = new BaseMockNode( 141 | { 142 | displayName: 'HTTP Request', 143 | name: 'httpRequest', 144 | group: ['transform'], 145 | version: 3, 146 | description: 'Makes an HTTP request and returns the response', 147 | defaults: { name: 'HTTP Request' }, 148 | inputs: ['main'], 149 | outputs: ['main'], 150 | properties: [ 151 | { 152 | displayName: 'Method', 153 | name: 'method', 154 | type: 'options', 155 | default: 'GET', 156 | options: [ 157 | { name: 'GET', value: 'GET' }, 158 | { name: 'POST', value: 'POST' }, 159 | { name: 'PUT', value: 'PUT' }, 160 | { name: 'DELETE', value: 'DELETE' }, 161 | { name: 'HEAD', value: 'HEAD' }, 162 | { name: 'PATCH', value: 'PATCH' }, 163 | ], 164 | }, 165 | { 166 | displayName: 'URL', 167 | name: 'url', 168 | type: 'string', 169 | default: '', 170 | required: true, 171 | placeholder: 'https://example.com', 172 | }, 173 | { 174 | displayName: 'Authentication', 175 | name: 'authentication', 176 | type: 'options', 177 | default: 'none', 178 | options: [ 179 | { name: 'None', value: 'none' }, 180 | { name: 'Basic Auth', value: 'basicAuth' }, 181 | { name: 'Digest Auth', value: 'digestAuth' }, 182 | { name: 'Header Auth', value: 'headerAuth' }, 183 | { name: 'OAuth1', value: 'oAuth1' }, 184 | { name: 'OAuth2', value: 'oAuth2' }, 185 | ], 186 | }, 187 | { 188 | displayName: 'Response Format', 189 | name: 'responseFormat', 190 | type: 'options', 191 | default: 'json', 192 | options: [ 193 | { name: 'JSON', value: 'json' }, 194 | { name: 'String', value: 'string' }, 195 | { name: 'File', value: 'file' }, 196 | ], 197 | }, 198 | { 199 | displayName: 'Options', 200 | name: 'options', 201 | type: 'collection', 202 | placeholder: 'Add Option', 203 | default: {}, 204 | options: [ 205 | { 206 | displayName: 'Body Content Type', 207 | name: 'bodyContentType', 208 | type: 'options', 209 | default: 'json', 210 | options: [ 211 | { name: 'JSON', value: 'json' }, 212 | { name: 'Form Data', value: 'formData' }, 213 | { name: 'Form URL Encoded', value: 'form-urlencoded' }, 214 | { name: 'Raw', value: 'raw' }, 215 | ], 216 | }, 217 | { 218 | displayName: 'Headers', 219 | name: 'headers', 220 | type: 'fixedCollection', 221 | default: {}, 222 | typeOptions: { 223 | multipleValues: true, 224 | }, 225 | }, 226 | { 227 | displayName: 'Query Parameters', 228 | name: 'queryParameters', 229 | type: 'fixedCollection', 230 | default: {}, 231 | typeOptions: { 232 | multipleValues: true, 233 | }, 234 | }, 235 | ], 236 | }, 237 | ], 238 | }, 239 | async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { 240 | const items = this.getInputData(); 241 | const returnData: INodeExecutionData[] = []; 242 | 243 | for (let i = 0; i < items.length; i++) { 244 | const method = this.getNodeParameter('method', i) as string; 245 | const url = this.getNodeParameter('url', i) as string; 246 | 247 | // Mock response 248 | const response = { 249 | statusCode: 200, 250 | headers: {}, 251 | body: { success: true, method, url }, 252 | }; 253 | 254 | returnData.push({ 255 | json: response, 256 | }); 257 | } 258 | 259 | return [returnData]; 260 | } 261 | ); 262 | 263 | const mockSlackNode = new BaseMockNode( 264 | { 265 | displayName: 'Slack', 266 | name: 'slack', 267 | group: ['output'], 268 | version: 2, 269 | description: 'Send messages to Slack', 270 | defaults: { name: 'Slack' }, 271 | inputs: ['main'], 272 | outputs: ['main'], 273 | credentials: [ 274 | { 275 | name: 'slackApi', 276 | required: true, 277 | }, 278 | ], 279 | properties: [ 280 | { 281 | displayName: 'Resource', 282 | name: 'resource', 283 | type: 'options', 284 | default: 'message', 285 | options: [ 286 | { name: 'Channel', value: 'channel' }, 287 | { name: 'Message', value: 'message' }, 288 | { name: 'User', value: 'user' }, 289 | { name: 'File', value: 'file' }, 290 | ], 291 | }, 292 | { 293 | displayName: 'Operation', 294 | name: 'operation', 295 | type: 'options', 296 | displayOptions: { 297 | show: { 298 | resource: ['message'], 299 | }, 300 | }, 301 | default: 'post', 302 | options: [ 303 | { name: 'Post', value: 'post' }, 304 | { name: 'Update', value: 'update' }, 305 | { name: 'Delete', value: 'delete' }, 306 | ], 307 | }, 308 | { 309 | displayName: 'Channel', 310 | name: 'channel', 311 | type: 'options', 312 | typeOptions: { 313 | loadOptionsMethod: 'getChannels', 314 | }, 315 | displayOptions: { 316 | show: { 317 | resource: ['message'], 318 | operation: ['post'], 319 | }, 320 | }, 321 | default: '', 322 | required: true, 323 | }, 324 | { 325 | displayName: 'Text', 326 | name: 'text', 327 | type: 'string', 328 | typeOptions: { 329 | alwaysOpenEditWindow: true, 330 | }, 331 | displayOptions: { 332 | show: { 333 | resource: ['message'], 334 | operation: ['post'], 335 | }, 336 | }, 337 | default: '', 338 | required: true, 339 | }, 340 | ], 341 | }, 342 | async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { 343 | const items = this.getInputData(); 344 | const returnData: INodeExecutionData[] = []; 345 | 346 | for (let i = 0; i < items.length; i++) { 347 | const resource = this.getNodeParameter('resource', i) as string; 348 | const operation = this.getNodeParameter('operation', i) as string; 349 | 350 | // Mock response 351 | const response = { 352 | ok: true, 353 | channel: this.getNodeParameter('channel', i, '') as string, 354 | ts: Date.now().toString(), 355 | message: { 356 | text: this.getNodeParameter('text', i, '') as string, 357 | }, 358 | }; 359 | 360 | returnData.push({ 361 | json: response, 362 | }); 363 | } 364 | 365 | return [returnData]; 366 | } 367 | ); 368 | 369 | const mockFunctionNode = new BaseMockNode( 370 | { 371 | displayName: 'Function', 372 | name: 'function', 373 | group: ['transform'], 374 | version: 1, 375 | description: 'Execute custom JavaScript code', 376 | defaults: { name: 'Function' }, 377 | inputs: ['main'], 378 | outputs: ['main'], 379 | properties: [ 380 | { 381 | displayName: 'JavaScript Code', 382 | name: 'functionCode', 383 | type: 'string', 384 | typeOptions: { 385 | alwaysOpenEditWindow: true, 386 | codeAutocomplete: 'function', 387 | editor: 'code', 388 | rows: 10, 389 | }, 390 | default: 'return items;', 391 | description: 'JavaScript code to execute', 392 | }, 393 | ], 394 | }, 395 | async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { 396 | const items = this.getInputData(); 397 | const functionCode = this.getNodeParameter('functionCode', 0) as string; 398 | 399 | // Simple mock - just return items 400 | return [items]; 401 | } 402 | ); 403 | 404 | const mockNoOpNode = new BaseMockNode( 405 | { 406 | displayName: 'No Operation', 407 | name: 'noOp', 408 | group: ['utility'], 409 | version: 1, 410 | description: 'Does nothing', 411 | defaults: { name: 'No Op' }, 412 | inputs: ['main'], 413 | outputs: ['main'], 414 | properties: [], 415 | }, 416 | async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { 417 | return [this.getInputData()]; 418 | } 419 | ); 420 | 421 | const mockMergeNode = new BaseMockNode( 422 | { 423 | displayName: 'Merge', 424 | name: 'merge', 425 | group: ['transform'], 426 | version: 2, 427 | description: 'Merge multiple data streams', 428 | defaults: { name: 'Merge' }, 429 | inputs: ['main', 'main'], 430 | outputs: ['main'], 431 | properties: [ 432 | { 433 | displayName: 'Mode', 434 | name: 'mode', 435 | type: 'options', 436 | default: 'append', 437 | options: [ 438 | { name: 'Append', value: 'append' }, 439 | { name: 'Merge By Index', value: 'mergeByIndex' }, 440 | { name: 'Merge By Key', value: 'mergeByKey' }, 441 | { name: 'Multiplex', value: 'multiplex' }, 442 | ], 443 | }, 444 | ], 445 | }, 446 | async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { 447 | const mode = this.getNodeParameter('mode', 0) as string; 448 | 449 | // Mock merge - just return first input 450 | return [this.getInputData()]; 451 | } 452 | ); 453 | 454 | const mockIfNode = new BaseMockNode( 455 | { 456 | displayName: 'IF', 457 | name: 'if', 458 | group: ['transform'], 459 | version: 1, 460 | description: 'Conditional logic', 461 | defaults: { name: 'IF' }, 462 | inputs: ['main'], 463 | outputs: ['main', 'main'], 464 | // outputNames: ['true', 'false'], // Not a valid property in INodeTypeDescription 465 | properties: [ 466 | { 467 | displayName: 'Conditions', 468 | name: 'conditions', 469 | type: 'fixedCollection', 470 | typeOptions: { 471 | multipleValues: true, 472 | }, 473 | default: {}, 474 | options: [ 475 | { 476 | name: 'string', 477 | displayName: 'String', 478 | values: [ 479 | { 480 | displayName: 'Value 1', 481 | name: 'value1', 482 | type: 'string', 483 | default: '', 484 | }, 485 | { 486 | displayName: 'Operation', 487 | name: 'operation', 488 | type: 'options', 489 | default: 'equals', 490 | options: [ 491 | { name: 'Equals', value: 'equals' }, 492 | { name: 'Not Equals', value: 'notEquals' }, 493 | { name: 'Contains', value: 'contains' }, 494 | { name: 'Not Contains', value: 'notContains' }, 495 | ], 496 | }, 497 | { 498 | displayName: 'Value 2', 499 | name: 'value2', 500 | type: 'string', 501 | default: '', 502 | }, 503 | ], 504 | }, 505 | ], 506 | }, 507 | ], 508 | }, 509 | async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { 510 | const items = this.getInputData(); 511 | const trueItems: INodeExecutionData[] = []; 512 | const falseItems: INodeExecutionData[] = []; 513 | 514 | // Mock condition - split 50/50 515 | items.forEach((item, index) => { 516 | if (index % 2 === 0) { 517 | trueItems.push(item); 518 | } else { 519 | falseItems.push(item); 520 | } 521 | }); 522 | 523 | return [trueItems, falseItems]; 524 | } 525 | ); 526 | 527 | const mockSwitchNode = new BaseMockNode( 528 | { 529 | displayName: 'Switch', 530 | name: 'switch', 531 | group: ['transform'], 532 | version: 1, 533 | description: 'Route items based on conditions', 534 | defaults: { name: 'Switch' }, 535 | inputs: ['main'], 536 | outputs: ['main', 'main', 'main', 'main'], 537 | properties: [ 538 | { 539 | displayName: 'Mode', 540 | name: 'mode', 541 | type: 'options', 542 | default: 'expression', 543 | options: [ 544 | { name: 'Expression', value: 'expression' }, 545 | { name: 'Rules', value: 'rules' }, 546 | ], 547 | }, 548 | { 549 | displayName: 'Output', 550 | name: 'output', 551 | type: 'options', 552 | displayOptions: { 553 | show: { 554 | mode: ['expression'], 555 | }, 556 | }, 557 | default: 'all', 558 | options: [ 559 | { name: 'All', value: 'all' }, 560 | { name: 'First Match', value: 'firstMatch' }, 561 | ], 562 | }, 563 | ], 564 | }, 565 | async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { 566 | const items = this.getInputData(); 567 | 568 | // Mock routing - distribute evenly across outputs 569 | const outputs: INodeExecutionData[][] = [[], [], [], []]; 570 | items.forEach((item, index) => { 571 | outputs[index % 4].push(item); 572 | }); 573 | 574 | return outputs; 575 | } 576 | ); 577 | 578 | // Node registry 579 | const nodeRegistry = new Map<string, INodeType>([ 580 | ['webhook', mockWebhookNode], 581 | ['httpRequest', mockHttpRequestNode], 582 | ['slack', mockSlackNode], 583 | ['function', mockFunctionNode], 584 | ['noOp', mockNoOpNode], 585 | ['merge', mockMergeNode], 586 | ['if', mockIfNode], 587 | ['switch', mockSwitchNode], 588 | ]); 589 | 590 | // Export mock functions 591 | export const getNodeTypes = vi.fn(() => ({ 592 | getByName: vi.fn((name: string) => nodeRegistry.get(name)), 593 | getByNameAndVersion: vi.fn((name: string, version: number) => nodeRegistry.get(name)), 594 | })); 595 | 596 | // Export individual node classes for direct import 597 | export const Webhook = mockWebhookNode; 598 | export const HttpRequest = mockHttpRequestNode; 599 | export const Slack = mockSlackNode; 600 | export const Function = mockFunctionNode; 601 | export const NoOp = mockNoOpNode; 602 | export const Merge = mockMergeNode; 603 | export const If = mockIfNode; 604 | export const Switch = mockSwitchNode; 605 | 606 | // Test utility to override node behavior 607 | export const mockNodeBehavior = (nodeName: string, overrides: Partial<INodeType>) => { 608 | const existingNode = nodeRegistry.get(nodeName); 609 | if (!existingNode) { 610 | throw new Error(`Node ${nodeName} not found in registry`); 611 | } 612 | 613 | const updatedNode = new BaseMockNode( 614 | { ...existingNode.description, ...overrides.description }, 615 | overrides.execute || existingNode.execute, 616 | overrides.webhook || existingNode.webhook 617 | ); 618 | 619 | nodeRegistry.set(nodeName, updatedNode); 620 | return updatedNode; 621 | }; 622 | 623 | // Test utility to reset all mocks 624 | export const resetAllMocks = () => { 625 | getNodeTypes.mockClear(); 626 | nodeRegistry.forEach((node) => { 627 | if (node.execute && vi.isMockFunction(node.execute)) { 628 | node.execute.mockClear(); 629 | } 630 | if (node.webhook && vi.isMockFunction(node.webhook)) { 631 | node.webhook.mockClear(); 632 | } 633 | }); 634 | }; 635 | 636 | // Test utility to add custom nodes 637 | export const registerMockNode = (name: string, node: INodeType) => { 638 | nodeRegistry.set(name, node); 639 | }; 640 | 641 | // Export default for require() compatibility 642 | export default { 643 | getNodeTypes, 644 | Webhook, 645 | HttpRequest, 646 | Slack, 647 | Function, 648 | NoOp, 649 | Merge, 650 | If, 651 | Switch, 652 | mockNodeBehavior, 653 | resetAllMocks, 654 | registerMockNode, 655 | }; ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/workflow-error-validation.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; 3 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 4 | import { TestableN8NMCPServer } from './test-helpers'; 5 | 6 | describe('MCP Workflow Error Output Validation Integration', () => { 7 | let mcpServer: TestableN8NMCPServer; 8 | let client: Client; 9 | 10 | beforeEach(async () => { 11 | mcpServer = new TestableN8NMCPServer(); 12 | await mcpServer.initialize(); 13 | 14 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 15 | await mcpServer.connectToTransport(serverTransport); 16 | 17 | client = new Client({ 18 | name: 'test-client', 19 | version: '1.0.0' 20 | }, { 21 | capabilities: {} 22 | }); 23 | 24 | await client.connect(clientTransport); 25 | }); 26 | 27 | afterEach(async () => { 28 | await client.close(); 29 | await mcpServer.close(); 30 | }); 31 | 32 | describe('validate_workflow tool - Error Output Configuration', () => { 33 | it('should detect incorrect error output configuration via MCP', async () => { 34 | const workflow = { 35 | nodes: [ 36 | { 37 | id: '1', 38 | name: 'Validate Input', 39 | type: 'n8n-nodes-base.set', 40 | typeVersion: 3.4, 41 | position: [-400, 64], 42 | parameters: {} 43 | }, 44 | { 45 | id: '2', 46 | name: 'Filter URLs', 47 | type: 'n8n-nodes-base.filter', 48 | typeVersion: 2.2, 49 | position: [-176, 64], 50 | parameters: {} 51 | }, 52 | { 53 | id: '3', 54 | name: 'Error Response1', 55 | type: 'n8n-nodes-base.respondToWebhook', 56 | typeVersion: 1.5, 57 | position: [-160, 240], 58 | parameters: {} 59 | } 60 | ], 61 | connections: { 62 | 'Validate Input': { 63 | main: [ 64 | [ 65 | { node: 'Filter URLs', type: 'main', index: 0 }, 66 | { node: 'Error Response1', type: 'main', index: 0 } // WRONG! Both in main[0] 67 | ] 68 | ] 69 | } 70 | } 71 | }; 72 | 73 | const response = await client.callTool({ 74 | name: 'validate_workflow', 75 | arguments: { workflow } 76 | }); 77 | 78 | expect((response as any).content).toHaveLength(1); 79 | expect((response as any).content[0].type).toBe('text'); 80 | 81 | const result = JSON.parse(((response as any).content[0]).text); 82 | 83 | expect(result.valid).toBe(false); 84 | expect(Array.isArray(result.errors)).toBe(true); 85 | 86 | // Check for the specific error message about incorrect configuration 87 | const hasIncorrectConfigError = result.errors.some((e: any) => 88 | e.message.includes('Incorrect error output configuration') && 89 | e.message.includes('Error Response1') && 90 | e.message.includes('appear to be error handlers but are in main[0]') 91 | ); 92 | expect(hasIncorrectConfigError).toBe(true); 93 | 94 | // Verify the error message includes the JSON examples 95 | const errorMsg = result.errors.find((e: any) => 96 | e.message.includes('Incorrect error output configuration') 97 | ); 98 | expect(errorMsg?.message).toContain('INCORRECT (current)'); 99 | expect(errorMsg?.message).toContain('CORRECT (should be)'); 100 | expect(errorMsg?.message).toContain('main[1] = error output'); 101 | }); 102 | 103 | it('should validate correct error output configuration via MCP', async () => { 104 | const workflow = { 105 | nodes: [ 106 | { 107 | id: '1', 108 | name: 'Validate Input', 109 | type: 'n8n-nodes-base.set', 110 | typeVersion: 3.4, 111 | position: [-400, 64], 112 | parameters: {}, 113 | onError: 'continueErrorOutput' 114 | }, 115 | { 116 | id: '2', 117 | name: 'Filter URLs', 118 | type: 'n8n-nodes-base.filter', 119 | typeVersion: 2.2, 120 | position: [-176, 64], 121 | parameters: {} 122 | }, 123 | { 124 | id: '3', 125 | name: 'Error Response1', 126 | type: 'n8n-nodes-base.respondToWebhook', 127 | typeVersion: 1.5, 128 | position: [-160, 240], 129 | parameters: {} 130 | } 131 | ], 132 | connections: { 133 | 'Validate Input': { 134 | main: [ 135 | [ 136 | { node: 'Filter URLs', type: 'main', index: 0 } 137 | ], 138 | [ 139 | { node: 'Error Response1', type: 'main', index: 0 } // Correctly in main[1] 140 | ] 141 | ] 142 | } 143 | } 144 | }; 145 | 146 | const response = await client.callTool({ 147 | name: 'validate_workflow', 148 | arguments: { workflow } 149 | }); 150 | 151 | expect((response as any).content).toHaveLength(1); 152 | expect((response as any).content[0].type).toBe('text'); 153 | 154 | const result = JSON.parse(((response as any).content[0]).text); 155 | 156 | // Should not have the specific error about incorrect configuration 157 | const hasIncorrectConfigError = result.errors?.some((e: any) => 158 | e.message.includes('Incorrect error output configuration') 159 | ) ?? false; 160 | expect(hasIncorrectConfigError).toBe(false); 161 | }); 162 | 163 | it('should detect onError and connection mismatches via MCP', async () => { 164 | // Test case 1: onError set but no error connections 165 | const workflow1 = { 166 | nodes: [ 167 | { 168 | id: '1', 169 | name: 'HTTP Request', 170 | type: 'n8n-nodes-base.httpRequest', 171 | typeVersion: 4, 172 | position: [100, 100], 173 | parameters: {}, 174 | onError: 'continueErrorOutput' 175 | }, 176 | { 177 | id: '2', 178 | name: 'Process Data', 179 | type: 'n8n-nodes-base.set', 180 | position: [300, 100], 181 | parameters: {} 182 | } 183 | ], 184 | connections: { 185 | 'HTTP Request': { 186 | main: [ 187 | [ 188 | { node: 'Process Data', type: 'main', index: 0 } 189 | ] 190 | ] 191 | } 192 | } 193 | }; 194 | 195 | // Test case 2: error connections but no onError 196 | const workflow2 = { 197 | nodes: [ 198 | { 199 | id: '1', 200 | name: 'HTTP Request', 201 | type: 'n8n-nodes-base.httpRequest', 202 | typeVersion: 4, 203 | position: [100, 100], 204 | parameters: {} 205 | // No onError property 206 | }, 207 | { 208 | id: '2', 209 | name: 'Process Data', 210 | type: 'n8n-nodes-base.set', 211 | position: [300, 100], 212 | parameters: {} 213 | }, 214 | { 215 | id: '3', 216 | name: 'Error Handler', 217 | type: 'n8n-nodes-base.set', 218 | position: [300, 200], 219 | parameters: {} 220 | } 221 | ], 222 | connections: { 223 | 'HTTP Request': { 224 | main: [ 225 | [ 226 | { node: 'Process Data', type: 'main', index: 0 } 227 | ], 228 | [ 229 | { node: 'Error Handler', type: 'main', index: 0 } 230 | ] 231 | ] 232 | } 233 | } 234 | }; 235 | 236 | // Test both scenarios 237 | const workflows = [workflow1, workflow2]; 238 | 239 | for (const workflow of workflows) { 240 | const response = await client.callTool({ 241 | name: 'validate_workflow', 242 | arguments: { workflow } 243 | }); 244 | 245 | const result = JSON.parse(((response as any).content[0]).text); 246 | 247 | // Should detect some kind of validation issue 248 | expect(result).toHaveProperty('valid'); 249 | expect(Array.isArray(result.errors || [])).toBe(true); 250 | expect(Array.isArray(result.warnings || [])).toBe(true); 251 | } 252 | }); 253 | 254 | it('should handle large workflows with complex error patterns via MCP', async () => { 255 | // Create a large workflow with multiple error handling scenarios 256 | const nodes = []; 257 | const connections: any = {}; 258 | 259 | // Create 50 nodes with various error handling patterns 260 | for (let i = 1; i <= 50; i++) { 261 | nodes.push({ 262 | id: i.toString(), 263 | name: `Node${i}`, 264 | type: i % 5 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set', 265 | typeVersion: 1, 266 | position: [i * 100, 100], 267 | parameters: {}, 268 | ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {}) 269 | }); 270 | } 271 | 272 | // Create connections with mixed correct and incorrect error handling 273 | for (let i = 1; i < 50; i++) { 274 | const hasErrorHandling = i % 3 === 0; 275 | const nextNode = `Node${i + 1}`; 276 | 277 | if (hasErrorHandling && i % 6 === 0) { 278 | // Incorrect: error handler in main[0] with success node 279 | connections[`Node${i}`] = { 280 | main: [ 281 | [ 282 | { node: nextNode, type: 'main', index: 0 }, 283 | { node: 'Error Handler', type: 'main', index: 0 } // Wrong placement 284 | ] 285 | ] 286 | }; 287 | } else if (hasErrorHandling) { 288 | // Correct: separate success and error outputs 289 | connections[`Node${i}`] = { 290 | main: [ 291 | [ 292 | { node: nextNode, type: 'main', index: 0 } 293 | ], 294 | [ 295 | { node: 'Error Handler', type: 'main', index: 0 } 296 | ] 297 | ] 298 | }; 299 | } else { 300 | // Normal connection 301 | connections[`Node${i}`] = { 302 | main: [ 303 | [ 304 | { node: nextNode, type: 'main', index: 0 } 305 | ] 306 | ] 307 | }; 308 | } 309 | } 310 | 311 | // Add error handler node 312 | nodes.push({ 313 | id: '51', 314 | name: 'Error Handler', 315 | type: 'n8n-nodes-base.set', 316 | typeVersion: 1, 317 | position: [2600, 200], 318 | parameters: {} 319 | }); 320 | 321 | const workflow = { nodes, connections }; 322 | 323 | const startTime = Date.now(); 324 | const response = await client.callTool({ 325 | name: 'validate_workflow', 326 | arguments: { workflow } 327 | }); 328 | const endTime = Date.now(); 329 | 330 | // Validation should complete quickly even for large workflows 331 | expect(endTime - startTime).toBeLessThan(5000); // Less than 5 seconds 332 | 333 | const result = JSON.parse(((response as any).content[0]).text); 334 | 335 | // Should detect the incorrect error configurations 336 | const hasErrors = result.errors && result.errors.length > 0; 337 | expect(hasErrors).toBe(true); 338 | 339 | // Specifically check for incorrect error output configuration errors 340 | const incorrectConfigErrors = result.errors.filter((e: any) => 341 | e.message.includes('Incorrect error output configuration') 342 | ); 343 | expect(incorrectConfigErrors.length).toBeGreaterThan(0); 344 | }); 345 | 346 | it('should handle edge cases gracefully via MCP', async () => { 347 | const edgeCaseWorkflows = [ 348 | // Empty workflow 349 | { nodes: [], connections: {} }, 350 | 351 | // Single isolated node 352 | { 353 | nodes: [{ 354 | id: '1', 355 | name: 'Isolated', 356 | type: 'n8n-nodes-base.set', 357 | position: [100, 100], 358 | parameters: {} 359 | }], 360 | connections: {} 361 | }, 362 | 363 | // Node with null/undefined connections 364 | { 365 | nodes: [{ 366 | id: '1', 367 | name: 'Source', 368 | type: 'n8n-nodes-base.httpRequest', 369 | position: [100, 100], 370 | parameters: {} 371 | }], 372 | connections: { 373 | 'Source': { 374 | main: [null, undefined] 375 | } 376 | } 377 | } 378 | ]; 379 | 380 | for (const workflow of edgeCaseWorkflows) { 381 | const response = await client.callTool({ 382 | name: 'validate_workflow', 383 | arguments: { workflow } 384 | }); 385 | 386 | expect((response as any).content).toHaveLength(1); 387 | const result = JSON.parse(((response as any).content[0]).text); 388 | 389 | // Should not crash and should return a valid validation result 390 | expect(result).toHaveProperty('valid'); 391 | expect(typeof result.valid).toBe('boolean'); 392 | expect(Array.isArray(result.errors || [])).toBe(true); 393 | expect(Array.isArray(result.warnings || [])).toBe(true); 394 | } 395 | }); 396 | 397 | it('should validate with different validation profiles via MCP', async () => { 398 | const workflow = { 399 | nodes: [ 400 | { 401 | id: '1', 402 | name: 'API Call', 403 | type: 'n8n-nodes-base.httpRequest', 404 | position: [100, 100], 405 | parameters: {} 406 | }, 407 | { 408 | id: '2', 409 | name: 'Success Handler', 410 | type: 'n8n-nodes-base.set', 411 | position: [300, 100], 412 | parameters: {} 413 | }, 414 | { 415 | id: '3', 416 | name: 'Error Response', 417 | type: 'n8n-nodes-base.respondToWebhook', 418 | position: [300, 200], 419 | parameters: {} 420 | } 421 | ], 422 | connections: { 423 | 'API Call': { 424 | main: [ 425 | [ 426 | { node: 'Success Handler', type: 'main', index: 0 }, 427 | { node: 'Error Response', type: 'main', index: 0 } // Incorrect placement 428 | ] 429 | ] 430 | } 431 | } 432 | }; 433 | 434 | const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict']; 435 | 436 | for (const profile of profiles) { 437 | const response = await client.callTool({ 438 | name: 'validate_workflow', 439 | arguments: { 440 | workflow, 441 | options: { profile } 442 | } 443 | }); 444 | 445 | const result = JSON.parse(((response as any).content[0]).text); 446 | 447 | // All profiles should detect this error output configuration issue 448 | const hasIncorrectConfigError = result.errors?.some((e: any) => 449 | e.message.includes('Incorrect error output configuration') 450 | ); 451 | expect(hasIncorrectConfigError).toBe(true); 452 | } 453 | }); 454 | }); 455 | 456 | describe('Error Message Format Consistency', () => { 457 | it('should format error messages consistently across different scenarios', async () => { 458 | const scenarios = [ 459 | { 460 | name: 'Single error handler in wrong place', 461 | workflow: { 462 | nodes: [ 463 | { id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, 464 | { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, 465 | { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} } 466 | ], 467 | connections: { 468 | 'Source': { 469 | main: [[ 470 | { node: 'Success', type: 'main', index: 0 }, 471 | { node: 'Error Handler', type: 'main', index: 0 } 472 | ]] 473 | } 474 | } 475 | } 476 | }, 477 | { 478 | name: 'Multiple error handlers in wrong place', 479 | workflow: { 480 | nodes: [ 481 | { id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, 482 | { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, 483 | { id: '3', name: 'Error Handler 1', type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} }, 484 | { id: '4', name: 'Error Handler 2', type: 'n8n-nodes-base.emailSend', position: [200, 200], parameters: {} } 485 | ], 486 | connections: { 487 | 'Source': { 488 | main: [[ 489 | { node: 'Success', type: 'main', index: 0 }, 490 | { node: 'Error Handler 1', type: 'main', index: 0 }, 491 | { node: 'Error Handler 2', type: 'main', index: 0 } 492 | ]] 493 | } 494 | } 495 | } 496 | } 497 | ]; 498 | 499 | for (const scenario of scenarios) { 500 | const response = await client.callTool({ 501 | name: 'validate_workflow', 502 | arguments: { workflow: scenario.workflow } 503 | }); 504 | 505 | const result = JSON.parse(((response as any).content[0]).text); 506 | 507 | const errorConfigError = result.errors.find((e: any) => 508 | e.message.includes('Incorrect error output configuration') 509 | ); 510 | 511 | expect(errorConfigError).toBeDefined(); 512 | 513 | // Check that error message follows consistent format 514 | expect(errorConfigError.message).toContain('INCORRECT (current):'); 515 | expect(errorConfigError.message).toContain('CORRECT (should be):'); 516 | expect(errorConfigError.message).toContain('main[0] = success output'); 517 | expect(errorConfigError.message).toContain('main[1] = error output'); 518 | expect(errorConfigError.message).toContain('Also add: "onError": "continueErrorOutput"'); 519 | 520 | // Check JSON format is valid 521 | const incorrectSection = errorConfigError.message.match(/INCORRECT \(current\):\n([\s\S]*?)\n\nCORRECT/); 522 | const correctSection = errorConfigError.message.match(/CORRECT \(should be\):\n([\s\S]*?)\n\nAlso add/); 523 | 524 | expect(incorrectSection).toBeDefined(); 525 | expect(correctSection).toBeDefined(); 526 | 527 | // Verify JSON structure is present (but don't parse due to comments) 528 | expect(incorrectSection).toBeDefined(); 529 | expect(correctSection).toBeDefined(); 530 | expect(incorrectSection![1]).toContain('main'); 531 | expect(correctSection![1]).toContain('main'); 532 | } 533 | }); 534 | }); 535 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-performance.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { WorkflowValidator } from '@/services/workflow-validator'; 3 | import { NodeRepository } from '@/database/node-repository'; 4 | import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; 5 | 6 | vi.mock('@/utils/logger'); 7 | 8 | describe('WorkflowValidator - Performance Tests', () => { 9 | let validator: WorkflowValidator; 10 | let mockNodeRepository: any; 11 | 12 | beforeEach(() => { 13 | vi.clearAllMocks(); 14 | 15 | // Create mock repository with performance optimizations 16 | mockNodeRepository = { 17 | getNode: vi.fn((type: string) => { 18 | // Return mock node info for any node type to avoid database calls 19 | return { 20 | node_type: type, 21 | display_name: 'Mock Node', 22 | isVersioned: true, 23 | version: 1 24 | }; 25 | }) 26 | }; 27 | 28 | validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); 29 | }); 30 | 31 | describe('Large Workflow Performance', () => { 32 | it('should validate large workflows with many error paths efficiently', async () => { 33 | // Generate a large workflow with 500 nodes 34 | const nodeCount = 500; 35 | const nodes = []; 36 | const connections: any = {}; 37 | 38 | // Create nodes with various error handling patterns 39 | for (let i = 1; i <= nodeCount; i++) { 40 | nodes.push({ 41 | id: i.toString(), 42 | name: `Node${i}`, 43 | type: i % 5 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set', 44 | typeVersion: 1, 45 | position: [i * 10, (i % 10) * 100], 46 | parameters: {}, 47 | ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {}) 48 | }); 49 | } 50 | 51 | // Create connections with multiple error handling scenarios 52 | for (let i = 1; i < nodeCount; i++) { 53 | const hasErrorHandling = i % 3 === 0; 54 | const hasMultipleConnections = i % 7 === 0; 55 | 56 | if (hasErrorHandling && hasMultipleConnections) { 57 | // Mix correct and incorrect error handling patterns 58 | const isIncorrect = i % 14 === 0; 59 | 60 | if (isIncorrect) { 61 | // Incorrect: error handlers mixed with success nodes in main[0] 62 | connections[`Node${i}`] = { 63 | main: [ 64 | [ 65 | { node: `Node${i + 1}`, type: 'main', index: 0 }, 66 | { node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong! 67 | ] 68 | ] 69 | }; 70 | } else { 71 | // Correct: separate success and error outputs 72 | connections[`Node${i}`] = { 73 | main: [ 74 | [ 75 | { node: `Node${i + 1}`, type: 'main', index: 0 } 76 | ], 77 | [ 78 | { node: `Error Handler ${i}`, type: 'main', index: 0 } 79 | ] 80 | ] 81 | }; 82 | } 83 | 84 | // Add error handler node 85 | nodes.push({ 86 | id: `error-${i}`, 87 | name: `Error Handler ${i}`, 88 | type: 'n8n-nodes-base.respondToWebhook', 89 | typeVersion: 1, 90 | position: [(i + nodeCount) * 10, 500], 91 | parameters: {} 92 | }); 93 | } else { 94 | // Simple connection 95 | connections[`Node${i}`] = { 96 | main: [ 97 | [ 98 | { node: `Node${i + 1}`, type: 'main', index: 0 } 99 | ] 100 | ] 101 | }; 102 | } 103 | } 104 | 105 | const workflow = { nodes, connections }; 106 | 107 | const startTime = performance.now(); 108 | const result = await validator.validateWorkflow(workflow as any); 109 | const endTime = performance.now(); 110 | 111 | const executionTime = endTime - startTime; 112 | 113 | // Validation should complete within reasonable time 114 | expect(executionTime).toBeLessThan(10000); // Less than 10 seconds 115 | 116 | // Should still catch validation errors 117 | expect(Array.isArray(result.errors)).toBe(true); 118 | expect(Array.isArray(result.warnings)).toBe(true); 119 | 120 | // Should detect incorrect error configurations 121 | const incorrectConfigErrors = result.errors.filter(e => 122 | e.message.includes('Incorrect error output configuration') 123 | ); 124 | expect(incorrectConfigErrors.length).toBeGreaterThan(0); 125 | 126 | console.log(`Validated ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`); 127 | console.log(`Found ${result.errors.length} errors and ${result.warnings.length} warnings`); 128 | }); 129 | 130 | it('should handle deeply nested error handling chains efficiently', async () => { 131 | // Create a chain of error handlers, each with their own error handling 132 | const chainLength = 100; 133 | const nodes = []; 134 | const connections: any = {}; 135 | 136 | for (let i = 1; i <= chainLength; i++) { 137 | // Main processing node 138 | nodes.push({ 139 | id: `main-${i}`, 140 | name: `Main ${i}`, 141 | type: 'n8n-nodes-base.httpRequest', 142 | typeVersion: 1, 143 | position: [i * 150, 100], 144 | parameters: {}, 145 | onError: 'continueErrorOutput' 146 | }); 147 | 148 | // Error handler node 149 | nodes.push({ 150 | id: `error-${i}`, 151 | name: `Error Handler ${i}`, 152 | type: 'n8n-nodes-base.httpRequest', 153 | typeVersion: 1, 154 | position: [i * 150, 300], 155 | parameters: {}, 156 | onError: 'continueErrorOutput' 157 | }); 158 | 159 | // Fallback error node 160 | nodes.push({ 161 | id: `fallback-${i}`, 162 | name: `Fallback ${i}`, 163 | type: 'n8n-nodes-base.set', 164 | typeVersion: 1, 165 | position: [i * 150, 500], 166 | parameters: {} 167 | }); 168 | 169 | // Connections 170 | connections[`Main ${i}`] = { 171 | main: [ 172 | // Success path 173 | i < chainLength ? [{ node: `Main ${i + 1}`, type: 'main', index: 0 }] : [], 174 | // Error path 175 | [{ node: `Error Handler ${i}`, type: 'main', index: 0 }] 176 | ] 177 | }; 178 | 179 | connections[`Error Handler ${i}`] = { 180 | main: [ 181 | // Success path (continue to next error handler or end) 182 | [], 183 | // Error path (go to fallback) 184 | [{ node: `Fallback ${i}`, type: 'main', index: 0 }] 185 | ] 186 | }; 187 | } 188 | 189 | const workflow = { nodes, connections }; 190 | 191 | const startTime = performance.now(); 192 | const result = await validator.validateWorkflow(workflow as any); 193 | const endTime = performance.now(); 194 | 195 | const executionTime = endTime - startTime; 196 | 197 | // Should complete quickly even with complex nested error handling 198 | expect(executionTime).toBeLessThan(5000); // Less than 5 seconds 199 | 200 | // Should not have errors about incorrect configuration (this is correct) 201 | const incorrectConfigErrors = result.errors.filter(e => 202 | e.message.includes('Incorrect error output configuration') 203 | ); 204 | expect(incorrectConfigErrors.length).toBe(0); 205 | 206 | console.log(`Validated ${nodes.length} nodes with nested error handling in ${executionTime.toFixed(2)}ms`); 207 | }); 208 | 209 | it('should efficiently validate workflows with many parallel error paths', async () => { 210 | // Create a workflow with one source node that fans out to many parallel paths, 211 | // each with their own error handling 212 | const parallelPathCount = 200; 213 | const nodes = [ 214 | { 215 | id: 'source', 216 | name: 'Source', 217 | type: 'n8n-nodes-base.webhook', 218 | typeVersion: 1, 219 | position: [0, 0], 220 | parameters: {} 221 | } 222 | ]; 223 | const connections: any = { 224 | 'Source': { 225 | main: [[]] 226 | } 227 | }; 228 | 229 | // Create parallel paths 230 | for (let i = 1; i <= parallelPathCount; i++) { 231 | // Processing node 232 | nodes.push({ 233 | id: `process-${i}`, 234 | name: `Process ${i}`, 235 | type: 'n8n-nodes-base.httpRequest', 236 | typeVersion: 1, 237 | position: [200, i * 20], 238 | parameters: {}, 239 | onError: 'continueErrorOutput' 240 | } as any); 241 | 242 | // Success handler 243 | nodes.push({ 244 | id: `success-${i}`, 245 | name: `Success ${i}`, 246 | type: 'n8n-nodes-base.set', 247 | typeVersion: 1, 248 | position: [400, i * 20], 249 | parameters: {} 250 | }); 251 | 252 | // Error handler 253 | nodes.push({ 254 | id: `error-${i}`, 255 | name: `Error Handler ${i}`, 256 | type: 'n8n-nodes-base.respondToWebhook', 257 | typeVersion: 1, 258 | position: [400, i * 20 + 10], 259 | parameters: {} 260 | }); 261 | 262 | // Connect source to processing node 263 | connections['Source'].main[0].push({ 264 | node: `Process ${i}`, 265 | type: 'main', 266 | index: 0 267 | }); 268 | 269 | // Connect processing node to success and error handlers 270 | connections[`Process ${i}`] = { 271 | main: [ 272 | [{ node: `Success ${i}`, type: 'main', index: 0 }], 273 | [{ node: `Error Handler ${i}`, type: 'main', index: 0 }] 274 | ] 275 | }; 276 | } 277 | 278 | const workflow = { nodes, connections }; 279 | 280 | const startTime = performance.now(); 281 | const result = await validator.validateWorkflow(workflow as any); 282 | const endTime = performance.now(); 283 | 284 | const executionTime = endTime - startTime; 285 | 286 | // Should validate efficiently despite many parallel paths 287 | expect(executionTime).toBeLessThan(8000); // Less than 8 seconds 288 | 289 | // Should not have errors about incorrect configuration 290 | const incorrectConfigErrors = result.errors.filter(e => 291 | e.message.includes('Incorrect error output configuration') 292 | ); 293 | expect(incorrectConfigErrors.length).toBe(0); 294 | 295 | console.log(`Validated ${nodes.length} nodes with ${parallelPathCount} parallel error paths in ${executionTime.toFixed(2)}ms`); 296 | }); 297 | 298 | it('should handle worst-case scenario with many incorrect configurations efficiently', async () => { 299 | // Create a workflow where many nodes have the incorrect error configuration 300 | // This tests the performance of the error detection algorithm 301 | const nodeCount = 300; 302 | const nodes = []; 303 | const connections: any = {}; 304 | 305 | for (let i = 1; i <= nodeCount; i++) { 306 | // Main node 307 | nodes.push({ 308 | id: `main-${i}`, 309 | name: `Main ${i}`, 310 | type: 'n8n-nodes-base.httpRequest', 311 | typeVersion: 1, 312 | position: [i * 20, 100], 313 | parameters: {} 314 | }); 315 | 316 | // Success handler 317 | nodes.push({ 318 | id: `success-${i}`, 319 | name: `Success ${i}`, 320 | type: 'n8n-nodes-base.set', 321 | typeVersion: 1, 322 | position: [i * 20, 200], 323 | parameters: {} 324 | }); 325 | 326 | // Error handler (with error-indicating name) 327 | nodes.push({ 328 | id: `error-${i}`, 329 | name: `Error Handler ${i}`, 330 | type: 'n8n-nodes-base.respondToWebhook', 331 | typeVersion: 1, 332 | position: [i * 20, 300], 333 | parameters: {} 334 | }); 335 | 336 | // INCORRECT configuration: both success and error handlers in main[0] 337 | connections[`Main ${i}`] = { 338 | main: [ 339 | [ 340 | { node: `Success ${i}`, type: 'main', index: 0 }, 341 | { node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong! 342 | ] 343 | ] 344 | }; 345 | } 346 | 347 | const workflow = { nodes, connections }; 348 | 349 | const startTime = performance.now(); 350 | const result = await validator.validateWorkflow(workflow as any); 351 | const endTime = performance.now(); 352 | 353 | const executionTime = endTime - startTime; 354 | 355 | // Should complete within reasonable time even when generating many errors 356 | expect(executionTime).toBeLessThan(15000); // Less than 15 seconds 357 | 358 | // Should detect ALL incorrect configurations 359 | const incorrectConfigErrors = result.errors.filter(e => 360 | e.message.includes('Incorrect error output configuration') 361 | ); 362 | expect(incorrectConfigErrors.length).toBe(nodeCount); // One error per node 363 | 364 | console.log(`Detected ${incorrectConfigErrors.length} incorrect configurations in ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`); 365 | }); 366 | }); 367 | 368 | describe('Memory Usage and Optimization', () => { 369 | it('should not leak memory during large workflow validation', async () => { 370 | // Get initial memory usage 371 | const initialMemory = process.memoryUsage().heapUsed; 372 | 373 | // Validate multiple large workflows 374 | for (let run = 0; run < 5; run++) { 375 | const nodeCount = 200; 376 | const nodes = []; 377 | const connections: any = {}; 378 | 379 | for (let i = 1; i <= nodeCount; i++) { 380 | nodes.push({ 381 | id: i.toString(), 382 | name: `Node${i}`, 383 | type: 'n8n-nodes-base.httpRequest', 384 | typeVersion: 1, 385 | position: [i * 10, 100], 386 | parameters: {}, 387 | onError: 'continueErrorOutput' 388 | }); 389 | 390 | if (i > 1) { 391 | connections[`Node${i - 1}`] = { 392 | main: [ 393 | [{ node: `Node${i}`, type: 'main', index: 0 }], 394 | [{ node: `Error${i}`, type: 'main', index: 0 }] 395 | ] 396 | }; 397 | 398 | nodes.push({ 399 | id: `error-${i}`, 400 | name: `Error${i}`, 401 | type: 'n8n-nodes-base.set', 402 | typeVersion: 1, 403 | position: [i * 10, 200], 404 | parameters: {} 405 | }); 406 | } 407 | } 408 | 409 | const workflow = { nodes, connections }; 410 | await validator.validateWorkflow(workflow as any); 411 | 412 | // Force garbage collection if available 413 | if (global.gc) { 414 | global.gc(); 415 | } 416 | } 417 | 418 | const finalMemory = process.memoryUsage().heapUsed; 419 | const memoryIncrease = finalMemory - initialMemory; 420 | const memoryIncreaseMB = memoryIncrease / (1024 * 1024); 421 | 422 | // Memory increase should be reasonable (less than 50MB) 423 | expect(memoryIncreaseMB).toBeLessThan(50); 424 | 425 | console.log(`Memory increase after 5 large workflow validations: ${memoryIncreaseMB.toFixed(2)}MB`); 426 | }); 427 | 428 | it('should handle concurrent validation requests efficiently', async () => { 429 | // Create multiple validation requests that run concurrently 430 | const concurrentRequests = 10; 431 | const workflows = []; 432 | 433 | // Prepare workflows 434 | for (let r = 0; r < concurrentRequests; r++) { 435 | const nodeCount = 50; 436 | const nodes = []; 437 | const connections: any = {}; 438 | 439 | for (let i = 1; i <= nodeCount; i++) { 440 | nodes.push({ 441 | id: `${r}-${i}`, 442 | name: `R${r}Node${i}`, 443 | type: i % 2 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set', 444 | typeVersion: 1, 445 | position: [i * 20, r * 100], 446 | parameters: {}, 447 | ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {}) 448 | }); 449 | 450 | if (i > 1) { 451 | const hasError = i % 3 === 0; 452 | const isIncorrect = i % 6 === 0; 453 | 454 | if (hasError && isIncorrect) { 455 | // Incorrect configuration 456 | connections[`R${r}Node${i - 1}`] = { 457 | main: [ 458 | [ 459 | { node: `R${r}Node${i}`, type: 'main', index: 0 }, 460 | { node: `R${r}Error${i}`, type: 'main', index: 0 } // Wrong! 461 | ] 462 | ] 463 | }; 464 | 465 | nodes.push({ 466 | id: `${r}-error-${i}`, 467 | name: `R${r}Error${i}`, 468 | type: 'n8n-nodes-base.respondToWebhook', 469 | typeVersion: 1, 470 | position: [i * 20, r * 100 + 50], 471 | parameters: {} 472 | }); 473 | } else if (hasError) { 474 | // Correct configuration 475 | connections[`R${r}Node${i - 1}`] = { 476 | main: [ 477 | [{ node: `R${r}Node${i}`, type: 'main', index: 0 }], 478 | [{ node: `R${r}Error${i}`, type: 'main', index: 0 }] 479 | ] 480 | }; 481 | 482 | nodes.push({ 483 | id: `${r}-error-${i}`, 484 | name: `R${r}Error${i}`, 485 | type: 'n8n-nodes-base.set', 486 | typeVersion: 1, 487 | position: [i * 20, r * 100 + 50], 488 | parameters: {} 489 | }); 490 | } else { 491 | // Normal connection 492 | connections[`R${r}Node${i - 1}`] = { 493 | main: [ 494 | [{ node: `R${r}Node${i}`, type: 'main', index: 0 }] 495 | ] 496 | }; 497 | } 498 | } 499 | } 500 | 501 | workflows.push({ nodes, connections }); 502 | } 503 | 504 | // Run concurrent validations 505 | const startTime = performance.now(); 506 | const results = await Promise.all( 507 | workflows.map(workflow => validator.validateWorkflow(workflow as any)) 508 | ); 509 | const endTime = performance.now(); 510 | 511 | const totalTime = endTime - startTime; 512 | 513 | // All validations should complete 514 | expect(results).toHaveLength(concurrentRequests); 515 | 516 | // Each result should be valid 517 | results.forEach(result => { 518 | expect(Array.isArray(result.errors)).toBe(true); 519 | expect(Array.isArray(result.warnings)).toBe(true); 520 | }); 521 | 522 | // Concurrent execution should be efficient 523 | expect(totalTime).toBeLessThan(20000); // Less than 20 seconds total 524 | 525 | console.log(`Completed ${concurrentRequests} concurrent validations in ${totalTime.toFixed(2)}ms`); 526 | }); 527 | }); 528 | }); ``` -------------------------------------------------------------------------------- /src/mcp/tools-documentation.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { toolsDocumentation } from './tool-docs'; 2 | 3 | export function getToolDocumentation(toolName: string, depth: 'essentials' | 'full' = 'essentials'): string { 4 | // Check for special documentation topics 5 | if (toolName === 'javascript_code_node_guide') { 6 | return getJavaScriptCodeNodeGuide(depth); 7 | } 8 | if (toolName === 'python_code_node_guide') { 9 | return getPythonCodeNodeGuide(depth); 10 | } 11 | 12 | const tool = toolsDocumentation[toolName]; 13 | if (!tool) { 14 | return `Tool '${toolName}' not found. Use tools_documentation() to see available tools.`; 15 | } 16 | 17 | if (depth === 'essentials') { 18 | const { essentials } = tool; 19 | return `# ${tool.name} 20 | 21 | ${essentials.description} 22 | 23 | **Example**: ${essentials.example} 24 | 25 | **Key parameters**: ${essentials.keyParameters.join(', ')} 26 | 27 | **Performance**: ${essentials.performance} 28 | 29 | **Tips**: 30 | ${essentials.tips.map(tip => `- ${tip}`).join('\n')} 31 | 32 | For full documentation, use: tools_documentation({topic: "${toolName}", depth: "full"})`; 33 | } 34 | 35 | // Full documentation 36 | const { full } = tool; 37 | return `# ${tool.name} 38 | 39 | ${full.description} 40 | 41 | ## Parameters 42 | ${Object.entries(full.parameters).map(([param, info]) => 43 | `- **${param}** (${info.type}${info.required ? ', required' : ''}): ${info.description}` 44 | ).join('\n')} 45 | 46 | ## Returns 47 | ${full.returns} 48 | 49 | ## Examples 50 | ${full.examples.map(ex => `\`\`\`javascript\n${ex}\n\`\`\``).join('\n\n')} 51 | 52 | ## Common Use Cases 53 | ${full.useCases.map(uc => `- ${uc}`).join('\n')} 54 | 55 | ## Performance 56 | ${full.performance} 57 | 58 | ## Best Practices 59 | ${full.bestPractices.map(bp => `- ${bp}`).join('\n')} 60 | 61 | ## Common Pitfalls 62 | ${full.pitfalls.map(p => `- ${p}`).join('\n')} 63 | 64 | ## Related Tools 65 | ${full.relatedTools.map(t => `- ${t}`).join('\n')}`; 66 | } 67 | 68 | export function getToolsOverview(depth: 'essentials' | 'full' = 'essentials'): string { 69 | // Get version info from package.json 70 | const packageJson = require('../../package.json'); 71 | const supportedN8nVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, '') || 'latest'; 72 | 73 | if (depth === 'essentials') { 74 | return `# n8n MCP Tools Reference 75 | 76 | ## Important: Compatibility Notice 77 | ⚠️ This MCP server is tested with n8n version ${supportedN8nVersion}. 78 | Inform the user to check their n8n version matches or is compatible with the supported version listed above. 79 | 80 | ## Code Node Configuration 81 | When working with Code nodes, always start by calling the relevant guide: 82 | - tools_documentation({topic: "javascript_code_node_guide"}) for JavaScript Code nodes 83 | - tools_documentation({topic: "python_code_node_guide"}) for Python Code nodes 84 | 85 | ## Standard Workflow Pattern 86 | 87 | 1. **Find** the node you need: 88 | - search_nodes({query: "slack"}) - Search by keyword 89 | - list_nodes({category: "communication"}) - List by category 90 | - list_ai_tools() - List AI-capable nodes 91 | 92 | 2. **Configure** the node: 93 | - get_node_essentials("nodes-base.slack") - Get essential properties only (5KB) 94 | - get_node_info("nodes-base.slack") - Get complete schema (100KB+) 95 | - search_node_properties("nodes-base.slack", "auth") - Find specific properties 96 | 97 | 3. **Validate** before deployment: 98 | - validate_node_minimal("nodes-base.slack", config) - Check required fields 99 | - validate_node_operation("nodes-base.slack", config) - Full validation with fixes 100 | - validate_workflow(workflow) - Validate entire workflow 101 | 102 | ## Tool Categories 103 | 104 | **Discovery Tools** 105 | - search_nodes - Full-text search across all nodes 106 | - list_nodes - List nodes with filtering by category, package, or type 107 | - list_ai_tools - List all AI-capable nodes with usage guidance 108 | 109 | **Configuration Tools** 110 | - get_node_essentials - Returns 10-20 key properties with examples 111 | - get_node_info - Returns complete node schema with all properties 112 | - search_node_properties - Search for specific properties within a node 113 | - get_property_dependencies - Analyze property visibility dependencies 114 | 115 | **Validation Tools** 116 | - validate_node_minimal - Quick validation of required fields only 117 | - validate_node_operation - Full validation with operation awareness 118 | - validate_workflow - Complete workflow validation including connections 119 | 120 | **Template Tools** 121 | - list_tasks - List common task templates 122 | - get_node_for_task - Get pre-configured node for specific tasks 123 | - search_templates - Search workflow templates by keyword 124 | - get_template - Get complete workflow JSON by ID 125 | 126 | **n8n API Tools** (requires N8N_API_URL configuration) 127 | - n8n_create_workflow - Create new workflows 128 | - n8n_update_partial_workflow - Update workflows using diff operations 129 | - n8n_validate_workflow - Validate workflow from n8n instance 130 | - n8n_trigger_webhook_workflow - Trigger workflow execution 131 | 132 | ## Performance Characteristics 133 | - Instant (<10ms): search_nodes, list_nodes, get_node_essentials 134 | - Fast (<100ms): validate_node_minimal, get_node_for_task 135 | - Moderate (100-500ms): validate_workflow, get_node_info 136 | - Network-dependent: All n8n_* tools 137 | 138 | For comprehensive documentation on any tool: 139 | tools_documentation({topic: "tool_name", depth: "full"})`; 140 | } 141 | 142 | const categories = getAllCategories(); 143 | return `# n8n MCP Tools - Complete Reference 144 | 145 | ## Important: Compatibility Notice 146 | ⚠️ This MCP server is tested with n8n version ${supportedN8nVersion}. 147 | Run n8n_health_check() to verify your n8n instance compatibility and API connectivity. 148 | 149 | ## Code Node Guides 150 | For Code node configuration, use these comprehensive guides: 151 | - tools_documentation({topic: "javascript_code_node_guide", depth: "full"}) - JavaScript patterns, n8n variables, error handling 152 | - tools_documentation({topic: "python_code_node_guide", depth: "full"}) - Python patterns, data access, debugging 153 | 154 | ## All Available Tools by Category 155 | 156 | ${categories.map(cat => { 157 | const tools = getToolsByCategory(cat); 158 | const categoryName = cat.charAt(0).toUpperCase() + cat.slice(1).replace('_', ' '); 159 | return `### ${categoryName} 160 | ${tools.map(toolName => { 161 | const tool = toolsDocumentation[toolName]; 162 | return `- **${toolName}**: ${tool.essentials.description}`; 163 | }).join('\n')}`; 164 | }).join('\n\n')} 165 | 166 | ## Usage Notes 167 | - All node types require the "nodes-base." or "nodes-langchain." prefix 168 | - Use get_node_essentials() first for most tasks (95% smaller than get_node_info) 169 | - Validation profiles: minimal (editing), runtime (default), strict (deployment) 170 | - n8n API tools only available when N8N_API_URL and N8N_API_KEY are configured 171 | 172 | For detailed documentation on any tool: 173 | tools_documentation({topic: "tool_name", depth: "full"})`; 174 | } 175 | 176 | export function searchToolDocumentation(keyword: string): string[] { 177 | const results: string[] = []; 178 | 179 | for (const [toolName, tool] of Object.entries(toolsDocumentation)) { 180 | const searchText = `${toolName} ${tool.essentials.description} ${tool.full.description}`.toLowerCase(); 181 | if (searchText.includes(keyword.toLowerCase())) { 182 | results.push(toolName); 183 | } 184 | } 185 | 186 | return results; 187 | } 188 | 189 | export function getToolsByCategory(category: string): string[] { 190 | return Object.entries(toolsDocumentation) 191 | .filter(([_, tool]) => tool.category === category) 192 | .map(([name, _]) => name); 193 | } 194 | 195 | export function getAllCategories(): string[] { 196 | const categories = new Set<string>(); 197 | Object.values(toolsDocumentation).forEach(tool => { 198 | categories.add(tool.category); 199 | }); 200 | return Array.from(categories); 201 | } 202 | 203 | // Special documentation topics 204 | function getJavaScriptCodeNodeGuide(depth: 'essentials' | 'full' = 'essentials'): string { 205 | if (depth === 'essentials') { 206 | return `# JavaScript Code Node Guide 207 | 208 | Essential patterns for JavaScript in n8n Code nodes. 209 | 210 | **Key Concepts**: 211 | - Access all items: \`$input.all()\` (not items[0]) 212 | - Current item data: \`$json\` 213 | - Return format: \`[{json: {...}}]\` (array of objects) 214 | 215 | **Available Helpers**: 216 | - \`$helpers.httpRequest()\` - Make HTTP requests 217 | - \`$jmespath()\` - Query JSON data 218 | - \`DateTime\` - Luxon for date handling 219 | 220 | **Common Patterns**: 221 | \`\`\`javascript 222 | // Process all items 223 | const allItems = $input.all(); 224 | return allItems.map(item => ({ 225 | json: { 226 | processed: true, 227 | original: item.json, 228 | timestamp: DateTime.now().toISO() 229 | } 230 | })); 231 | \`\`\` 232 | 233 | **Tips**: 234 | - Webhook data is under \`.body\` property 235 | - Use async/await for HTTP requests 236 | - Always return array format 237 | 238 | For full guide: tools_documentation({topic: "javascript_code_node_guide", depth: "full"})`; 239 | } 240 | 241 | // Full documentation 242 | return `# JavaScript Code Node Complete Guide 243 | 244 | Comprehensive guide for using JavaScript in n8n Code nodes. 245 | 246 | ## Data Access Patterns 247 | 248 | ### Accessing Input Data 249 | \`\`\`javascript 250 | // Get all items from previous node 251 | const allItems = $input.all(); 252 | 253 | // Get specific node's output 254 | const webhookData = $node["Webhook"].json; 255 | 256 | // Current item in loop 257 | const currentItem = $json; 258 | 259 | // First item only 260 | const firstItem = $input.first().json; 261 | \`\`\` 262 | 263 | ### Webhook Data Structure 264 | **CRITICAL**: Webhook data is nested under \`.body\`: 265 | \`\`\`javascript 266 | // WRONG - Won't work 267 | const data = $json.name; 268 | 269 | // CORRECT - Webhook data is under body 270 | const data = $json.body.name; 271 | \`\`\` 272 | 273 | ## Available Built-in Functions 274 | 275 | ### HTTP Requests 276 | \`\`\`javascript 277 | // Make HTTP request 278 | const response = await $helpers.httpRequest({ 279 | method: 'GET', 280 | url: 'https://api.example.com/data', 281 | headers: { 282 | 'Authorization': 'Bearer token' 283 | } 284 | }); 285 | \`\`\` 286 | 287 | ### Date/Time Handling 288 | \`\`\`javascript 289 | // Using Luxon DateTime 290 | const now = DateTime.now(); 291 | const formatted = now.toFormat('yyyy-MM-dd'); 292 | const iso = now.toISO(); 293 | const plus5Days = now.plus({ days: 5 }); 294 | \`\`\` 295 | 296 | ### JSON Querying 297 | \`\`\`javascript 298 | // JMESPath queries 299 | const result = $jmespath($json, "users[?age > 30].name"); 300 | \`\`\` 301 | 302 | ## Return Format Requirements 303 | 304 | ### Correct Format 305 | \`\`\`javascript 306 | // MUST return array of objects with json property 307 | return [{ 308 | json: { 309 | result: "success", 310 | data: processedData 311 | } 312 | }]; 313 | 314 | // Multiple items 315 | return items.map(item => ({ 316 | json: { 317 | id: item.id, 318 | processed: true 319 | } 320 | })); 321 | \`\`\` 322 | 323 | ### Binary Data 324 | \`\`\`javascript 325 | // Return with binary data 326 | return [{ 327 | json: { filename: "report.pdf" }, 328 | binary: { 329 | data: Buffer.from(pdfContent).toString('base64') 330 | } 331 | }]; 332 | \`\`\` 333 | 334 | ## Common Patterns 335 | 336 | ### Processing Webhook Data 337 | \`\`\`javascript 338 | // Extract webhook payload 339 | const webhookBody = $json.body; 340 | const { username, email, items } = webhookBody; 341 | 342 | // Process and return 343 | return [{ 344 | json: { 345 | username, 346 | email, 347 | itemCount: items.length, 348 | processedAt: DateTime.now().toISO() 349 | } 350 | }]; 351 | \`\`\` 352 | 353 | ### Aggregating Data 354 | \`\`\`javascript 355 | // Sum values across all items 356 | const allItems = $input.all(); 357 | const total = allItems.reduce((sum, item) => { 358 | return sum + (item.json.amount || 0); 359 | }, 0); 360 | 361 | return [{ 362 | json: { 363 | total, 364 | itemCount: allItems.length, 365 | average: total / allItems.length 366 | } 367 | }]; 368 | \`\`\` 369 | 370 | ### Error Handling 371 | \`\`\`javascript 372 | try { 373 | const response = await $helpers.httpRequest({ 374 | url: 'https://api.example.com/data' 375 | }); 376 | 377 | return [{ 378 | json: { 379 | success: true, 380 | data: response 381 | } 382 | }]; 383 | } catch (error) { 384 | return [{ 385 | json: { 386 | success: false, 387 | error: error.message 388 | } 389 | }]; 390 | } 391 | \`\`\` 392 | 393 | ## Available Node.js Modules 394 | - crypto (built-in) 395 | - Buffer 396 | - URL/URLSearchParams 397 | - Basic Node.js globals 398 | 399 | ## Common Pitfalls 400 | 1. Using \`items[0]\` instead of \`$input.all()\` 401 | 2. Forgetting webhook data is under \`.body\` 402 | 3. Returning plain objects instead of \`[{json: {...}}]\` 403 | 4. Using \`require()\` for external modules (not allowed) 404 | 5. Trying to use expression syntax \`{{}}\` inside code 405 | 406 | ## Best Practices 407 | 1. Always validate input data exists before accessing 408 | 2. Use try-catch for HTTP requests 409 | 3. Return early on validation failures 410 | 4. Keep code simple and readable 411 | 5. Use descriptive variable names 412 | 413 | ## Related Tools 414 | - get_node_essentials("nodes-base.code") 415 | - validate_node_operation() 416 | - python_code_node_guide (for Python syntax)`; 417 | } 418 | 419 | function getPythonCodeNodeGuide(depth: 'essentials' | 'full' = 'essentials'): string { 420 | if (depth === 'essentials') { 421 | return `# Python Code Node Guide 422 | 423 | Essential patterns for Python in n8n Code nodes. 424 | 425 | **Key Concepts**: 426 | - Access all items: \`_input.all()\` (not items[0]) 427 | - Current item data: \`_json\` 428 | - Return format: \`[{"json": {...}}]\` (list of dicts) 429 | 430 | **Limitations**: 431 | - No external libraries (no requests, pandas, numpy) 432 | - Use built-in functions only 433 | - No pip install available 434 | 435 | **Common Patterns**: 436 | \`\`\`python 437 | # Process all items 438 | all_items = _input.all() 439 | return [{ 440 | "json": { 441 | "processed": True, 442 | "count": len(all_items), 443 | "first_item": all_items[0]["json"] if all_items else None 444 | } 445 | }] 446 | \`\`\` 447 | 448 | **Tips**: 449 | - Webhook data is under ["body"] key 450 | - Use json module for parsing 451 | - datetime for date handling 452 | 453 | For full guide: tools_documentation({topic: "python_code_node_guide", depth: "full"})`; 454 | } 455 | 456 | // Full documentation 457 | return `# Python Code Node Complete Guide 458 | 459 | Comprehensive guide for using Python in n8n Code nodes. 460 | 461 | ## Data Access Patterns 462 | 463 | ### Accessing Input Data 464 | \`\`\`python 465 | # Get all items from previous node 466 | all_items = _input.all() 467 | 468 | # Get specific node's output (use _node) 469 | webhook_data = _node["Webhook"]["json"] 470 | 471 | # Current item in loop 472 | current_item = _json 473 | 474 | # First item only 475 | first_item = _input.first()["json"] 476 | \`\`\` 477 | 478 | ### Webhook Data Structure 479 | **CRITICAL**: Webhook data is nested under ["body"]: 480 | \`\`\`python 481 | # WRONG - Won't work 482 | data = _json["name"] 483 | 484 | # CORRECT - Webhook data is under body 485 | data = _json["body"]["name"] 486 | \`\`\` 487 | 488 | ## Available Built-in Modules 489 | 490 | ### Standard Library Only 491 | \`\`\`python 492 | import json 493 | import datetime 494 | import base64 495 | import hashlib 496 | import urllib.parse 497 | import re 498 | import math 499 | import random 500 | \`\`\` 501 | 502 | ### Date/Time Handling 503 | \`\`\`python 504 | from datetime import datetime, timedelta 505 | 506 | # Current time 507 | now = datetime.now() 508 | iso_format = now.isoformat() 509 | 510 | # Date arithmetic 511 | future = now + timedelta(days=5) 512 | formatted = now.strftime("%Y-%m-%d") 513 | \`\`\` 514 | 515 | ### JSON Operations 516 | \`\`\`python 517 | # Parse JSON string 518 | data = json.loads(json_string) 519 | 520 | # Convert to JSON 521 | json_output = json.dumps({"key": "value"}) 522 | \`\`\` 523 | 524 | ## Return Format Requirements 525 | 526 | ### Correct Format 527 | \`\`\`python 528 | # MUST return list of dictionaries with "json" key 529 | return [{ 530 | "json": { 531 | "result": "success", 532 | "data": processed_data 533 | } 534 | }] 535 | 536 | # Multiple items 537 | return [ 538 | {"json": {"id": item["json"]["id"], "processed": True}} 539 | for item in all_items 540 | ] 541 | \`\`\` 542 | 543 | ### Binary Data 544 | \`\`\`python 545 | # Return with binary data 546 | import base64 547 | 548 | return [{ 549 | "json": {"filename": "report.pdf"}, 550 | "binary": { 551 | "data": base64.b64encode(pdf_content).decode() 552 | } 553 | }] 554 | \`\`\` 555 | 556 | ## Common Patterns 557 | 558 | ### Processing Webhook Data 559 | \`\`\`python 560 | # Extract webhook payload 561 | webhook_body = _json["body"] 562 | username = webhook_body.get("username") 563 | email = webhook_body.get("email") 564 | items = webhook_body.get("items", []) 565 | 566 | # Process and return 567 | return [{ 568 | "json": { 569 | "username": username, 570 | "email": email, 571 | "item_count": len(items), 572 | "processed_at": datetime.now().isoformat() 573 | } 574 | }] 575 | \`\`\` 576 | 577 | ### Aggregating Data 578 | \`\`\`python 579 | # Sum values across all items 580 | all_items = _input.all() 581 | total = sum(item["json"].get("amount", 0) for item in all_items) 582 | 583 | return [{ 584 | "json": { 585 | "total": total, 586 | "item_count": len(all_items), 587 | "average": total / len(all_items) if all_items else 0 588 | } 589 | }] 590 | \`\`\` 591 | 592 | ### Error Handling 593 | \`\`\`python 594 | try: 595 | # Process data 596 | webhook_data = _json["body"] 597 | result = process_data(webhook_data) 598 | 599 | return [{ 600 | "json": { 601 | "success": True, 602 | "data": result 603 | } 604 | }] 605 | except Exception as e: 606 | return [{ 607 | "json": { 608 | "success": False, 609 | "error": str(e) 610 | } 611 | }] 612 | \`\`\` 613 | 614 | ### Data Transformation 615 | \`\`\`python 616 | # Transform all items 617 | all_items = _input.all() 618 | transformed = [] 619 | 620 | for item in all_items: 621 | data = item["json"] 622 | transformed.append({ 623 | "json": { 624 | "id": data.get("id"), 625 | "name": data.get("name", "").upper(), 626 | "timestamp": datetime.now().isoformat(), 627 | "valid": bool(data.get("email")) 628 | } 629 | }) 630 | 631 | return transformed 632 | \`\`\` 633 | 634 | ## Limitations & Workarounds 635 | 636 | ### No External Libraries 637 | \`\`\`python 638 | # CANNOT USE: 639 | # import requests # Not available 640 | # import pandas # Not available 641 | # import numpy # Not available 642 | 643 | # WORKAROUND: Use JavaScript Code node for HTTP requests 644 | # Or use HTTP Request node before Code node 645 | \`\`\` 646 | 647 | ### HTTP Requests Alternative 648 | Since Python requests library is not available, use: 649 | 1. JavaScript Code node with $helpers.httpRequest() 650 | 2. HTTP Request node before your Python Code node 651 | 3. Webhook node to receive data 652 | 653 | ## Common Pitfalls 654 | 1. Trying to import external libraries (requests, pandas) 655 | 2. Using items[0] instead of _input.all() 656 | 3. Forgetting webhook data is under ["body"] 657 | 4. Returning dictionaries instead of [{"json": {...}}] 658 | 5. Not handling missing keys with .get() 659 | 660 | ## Best Practices 661 | 1. Always use .get() for dictionary access 662 | 2. Validate data before processing 663 | 3. Handle empty input arrays 664 | 4. Use list comprehensions for transformations 665 | 5. Return meaningful error messages 666 | 667 | ## Type Conversions 668 | \`\`\`python 669 | # String to number 670 | value = float(_json.get("amount", "0")) 671 | 672 | # Boolean conversion 673 | is_active = str(_json.get("active", "")).lower() == "true" 674 | 675 | # Safe JSON parsing 676 | try: 677 | data = json.loads(_json.get("json_string", "{}")) 678 | except json.JSONDecodeError: 679 | data = {} 680 | \`\`\` 681 | 682 | ## Related Tools 683 | - get_node_essentials("nodes-base.code") 684 | - validate_node_operation() 685 | - javascript_code_node_guide (for JavaScript syntax)`; 686 | } ``` -------------------------------------------------------------------------------- /tests/unit/parsers/node-parser.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { NodeParser } from '@/parsers/node-parser'; 3 | import { PropertyExtractor } from '@/parsers/property-extractor'; 4 | import { 5 | programmaticNodeFactory, 6 | declarativeNodeFactory, 7 | triggerNodeFactory, 8 | webhookNodeFactory, 9 | aiToolNodeFactory, 10 | versionedNodeClassFactory, 11 | versionedNodeTypeClassFactory, 12 | malformedNodeFactory, 13 | nodeClassFactory, 14 | propertyFactory, 15 | stringPropertyFactory, 16 | optionsPropertyFactory 17 | } from '@tests/fixtures/factories/parser-node.factory'; 18 | 19 | // Mock PropertyExtractor 20 | vi.mock('@/parsers/property-extractor'); 21 | 22 | describe('NodeParser', () => { 23 | let parser: NodeParser; 24 | let mockPropertyExtractor: any; 25 | 26 | beforeEach(() => { 27 | vi.clearAllMocks(); 28 | 29 | // Setup mock property extractor 30 | mockPropertyExtractor = { 31 | extractProperties: vi.fn().mockReturnValue([]), 32 | extractCredentials: vi.fn().mockReturnValue([]), 33 | detectAIToolCapability: vi.fn().mockReturnValue(false), 34 | extractOperations: vi.fn().mockReturnValue([]) 35 | }; 36 | 37 | (PropertyExtractor as any).mockImplementation(() => mockPropertyExtractor); 38 | 39 | parser = new NodeParser(); 40 | }); 41 | 42 | describe('parse method', () => { 43 | it('should parse correctly when node is programmatic', () => { 44 | const nodeDefinition = programmaticNodeFactory.build(); 45 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 46 | 47 | mockPropertyExtractor.extractProperties.mockReturnValue(nodeDefinition.properties); 48 | mockPropertyExtractor.extractCredentials.mockReturnValue(nodeDefinition.credentials); 49 | 50 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 51 | 52 | expect(result).toMatchObject({ 53 | style: 'programmatic', 54 | nodeType: `nodes-base.${nodeDefinition.name}`, 55 | displayName: nodeDefinition.displayName, 56 | description: nodeDefinition.description, 57 | category: nodeDefinition.group?.[0] || 'misc', 58 | packageName: 'n8n-nodes-base' 59 | }); 60 | 61 | // Check specific properties separately to avoid strict matching 62 | expect(result.isVersioned).toBe(false); 63 | expect(result.version).toBe(nodeDefinition.version?.toString() || '1'); 64 | 65 | expect(mockPropertyExtractor.extractProperties).toHaveBeenCalledWith(NodeClass); 66 | expect(mockPropertyExtractor.extractCredentials).toHaveBeenCalledWith(NodeClass); 67 | }); 68 | 69 | it('should parse correctly when node is declarative', () => { 70 | const nodeDefinition = declarativeNodeFactory.build(); 71 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 72 | 73 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 74 | 75 | expect(result.style).toBe('declarative'); 76 | expect(result.nodeType).toBe(`nodes-base.${nodeDefinition.name}`); 77 | }); 78 | 79 | it('should preserve type when package prefix is already included', () => { 80 | const nodeDefinition = programmaticNodeFactory.build({ 81 | name: 'nodes-base.slack' 82 | }); 83 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 84 | 85 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 86 | 87 | expect(result.nodeType).toBe('nodes-base.slack'); 88 | }); 89 | 90 | it('should set isTrigger flag when node is a trigger', () => { 91 | const nodeDefinition = triggerNodeFactory.build(); 92 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 93 | 94 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 95 | 96 | expect(result.isTrigger).toBe(true); 97 | }); 98 | 99 | it('should set isWebhook flag when node is a webhook', () => { 100 | const nodeDefinition = webhookNodeFactory.build(); 101 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 102 | 103 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 104 | 105 | expect(result.isWebhook).toBe(true); 106 | }); 107 | 108 | it('should set isAITool flag when node has AI capability', () => { 109 | const nodeDefinition = aiToolNodeFactory.build(); 110 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 111 | 112 | mockPropertyExtractor.detectAIToolCapability.mockReturnValue(true); 113 | 114 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 115 | 116 | expect(result.isAITool).toBe(true); 117 | }); 118 | 119 | it('should parse correctly when node uses VersionedNodeType class', () => { 120 | // Create a simple versioned node class without modifying function properties 121 | const VersionedNodeClass = class VersionedNodeType { 122 | baseDescription = { 123 | name: 'versionedNode', 124 | displayName: 'Versioned Node', 125 | description: 'A versioned node', 126 | defaultVersion: 2 127 | }; 128 | nodeVersions = { 129 | 1: { description: { properties: [] } }, 130 | 2: { description: { properties: [] } } 131 | }; 132 | currentVersion = 2; 133 | }; 134 | 135 | mockPropertyExtractor.extractProperties.mockReturnValue([ 136 | propertyFactory.build(), 137 | propertyFactory.build() 138 | ]); 139 | 140 | const result = parser.parse(VersionedNodeClass as any, 'n8n-nodes-base'); 141 | 142 | expect(result.isVersioned).toBe(true); 143 | expect(result.version).toBe('2'); 144 | expect(result.nodeType).toBe('nodes-base.versionedNode'); 145 | }); 146 | 147 | it('should parse correctly when node has nodeVersions property', () => { 148 | const versionedDef = versionedNodeClassFactory.build(); 149 | const NodeClass = class { 150 | nodeVersions = versionedDef.nodeVersions; 151 | baseDescription = versionedDef.baseDescription; 152 | }; 153 | 154 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 155 | 156 | expect(result.isVersioned).toBe(true); 157 | expect(result.version).toBe('2'); 158 | }); 159 | 160 | it('should use max version when version is an array', () => { 161 | const nodeDefinition = programmaticNodeFactory.build({ 162 | version: [1, 1.1, 1.2, 2] 163 | }); 164 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 165 | 166 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 167 | 168 | expect(result.isVersioned).toBe(true); 169 | expect(result.version).toBe('2'); // Should return max version 170 | }); 171 | 172 | it('should throw error when node is missing name property', () => { 173 | const nodeDefinition = malformedNodeFactory.build(); 174 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 175 | 176 | expect(() => parser.parse(NodeClass as any, 'n8n-nodes-base')).toThrow('Node is missing name property'); 177 | }); 178 | 179 | it('should use static description when instantiation fails', () => { 180 | const NodeClass = class { 181 | static description = programmaticNodeFactory.build(); 182 | constructor() { 183 | throw new Error('Cannot instantiate'); 184 | } 185 | }; 186 | 187 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 188 | 189 | expect(result.displayName).toBe(NodeClass.description.displayName); 190 | }); 191 | 192 | it('should extract category when using different property names', () => { 193 | const testCases = [ 194 | { group: ['transform'], expected: 'transform' }, 195 | { categories: ['output'], expected: 'output' }, 196 | { category: 'trigger', expected: 'trigger' }, 197 | { /* no category */ expected: 'misc' } 198 | ]; 199 | 200 | testCases.forEach(({ group, categories, category, expected }) => { 201 | const nodeDefinition = programmaticNodeFactory.build({ 202 | group, 203 | categories, 204 | category 205 | } as any); 206 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 207 | 208 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 209 | 210 | expect(result.category).toBe(expected); 211 | }); 212 | }); 213 | 214 | it('should set isTrigger flag when node has polling property', () => { 215 | const nodeDefinition = programmaticNodeFactory.build({ 216 | polling: true 217 | }); 218 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 219 | 220 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 221 | 222 | expect(result.isTrigger).toBe(true); 223 | }); 224 | 225 | it('should set isTrigger flag when node has eventTrigger property', () => { 226 | const nodeDefinition = programmaticNodeFactory.build({ 227 | eventTrigger: true 228 | }); 229 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 230 | 231 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 232 | 233 | expect(result.isTrigger).toBe(true); 234 | }); 235 | 236 | it('should set isTrigger flag when node name contains trigger', () => { 237 | const nodeDefinition = programmaticNodeFactory.build({ 238 | name: 'myTrigger' 239 | }); 240 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 241 | 242 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 243 | 244 | expect(result.isTrigger).toBe(true); 245 | }); 246 | 247 | it('should set isWebhook flag when node name contains webhook', () => { 248 | const nodeDefinition = programmaticNodeFactory.build({ 249 | name: 'customWebhook' 250 | }); 251 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 252 | 253 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 254 | 255 | expect(result.isWebhook).toBe(true); 256 | }); 257 | 258 | it('should parse correctly when node is an instance object', () => { 259 | const nodeDefinition = programmaticNodeFactory.build(); 260 | const nodeInstance = { 261 | description: nodeDefinition 262 | }; 263 | 264 | mockPropertyExtractor.extractProperties.mockReturnValue(nodeDefinition.properties); 265 | 266 | const result = parser.parse(nodeInstance as any, 'n8n-nodes-base'); 267 | 268 | expect(result.displayName).toBe(nodeDefinition.displayName); 269 | }); 270 | 271 | it('should handle different package name formats', () => { 272 | const nodeDefinition = programmaticNodeFactory.build(); 273 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 274 | 275 | const testCases = [ 276 | { packageName: '@n8n/n8n-nodes-langchain', expectedPrefix: 'nodes-langchain' }, 277 | { packageName: 'n8n-nodes-custom', expectedPrefix: 'nodes-custom' }, 278 | { packageName: 'custom-package', expectedPrefix: 'custom-package' } 279 | ]; 280 | 281 | testCases.forEach(({ packageName, expectedPrefix }) => { 282 | const result = parser.parse(NodeClass as any, packageName); 283 | expect(result.nodeType).toBe(`${expectedPrefix}.${nodeDefinition.name}`); 284 | }); 285 | }); 286 | }); 287 | 288 | describe('version extraction', () => { 289 | it('should prioritize currentVersion over description.defaultVersion', () => { 290 | const NodeClass = class { 291 | currentVersion = 2.2; // Should be returned 292 | description = { 293 | name: 'AI Agent', 294 | displayName: 'AI Agent', 295 | defaultVersion: 3 // Should be ignored when currentVersion exists 296 | }; 297 | }; 298 | 299 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 300 | 301 | expect(result.version).toBe('2.2'); 302 | }); 303 | 304 | it('should extract version from description.defaultVersion', () => { 305 | const NodeClass = class { 306 | description = { 307 | name: 'test', 308 | displayName: 'Test', 309 | defaultVersion: 3 310 | }; 311 | }; 312 | 313 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 314 | 315 | expect(result.version).toBe('3'); 316 | }); 317 | 318 | it('should handle currentVersion = 0 correctly', () => { 319 | const NodeClass = class { 320 | currentVersion = 0; // Edge case: version 0 should be valid 321 | description = { 322 | name: 'test', 323 | displayName: 'Test', 324 | defaultVersion: 5 // Should be ignored 325 | }; 326 | }; 327 | 328 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 329 | 330 | expect(result.version).toBe('0'); 331 | }); 332 | 333 | it('should NOT extract version from non-existent baseDescription (legacy bug)', () => { 334 | const NodeClass = class { 335 | baseDescription = { // This property doesn't exist on VersionedNodeType! 336 | name: 'test', 337 | displayName: 'Test', 338 | defaultVersion: 3 339 | }; 340 | }; 341 | 342 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 343 | 344 | expect(result.version).toBe('1'); // Should fallback to default 345 | }); 346 | 347 | it('should extract version from nodeVersions keys', () => { 348 | const NodeClass = class { 349 | description = { name: 'test', displayName: 'Test' }; 350 | nodeVersions = { 351 | 1: { description: {} }, 352 | 2: { description: {} }, 353 | 3: { description: {} } 354 | }; 355 | }; 356 | 357 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 358 | 359 | expect(result.version).toBe('3'); 360 | }); 361 | 362 | it('should extract version from instance nodeVersions', () => { 363 | const NodeClass = class { 364 | description = { name: 'test', displayName: 'Test' }; 365 | 366 | constructor() { 367 | (this as any).nodeVersions = { 368 | 1: { description: {} }, 369 | 2: { description: {} }, 370 | 4: { description: {} } 371 | }; 372 | } 373 | }; 374 | 375 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 376 | 377 | expect(result.version).toBe('4'); 378 | }); 379 | 380 | it('should handle version as number in description', () => { 381 | const nodeDefinition = programmaticNodeFactory.build({ 382 | version: 2 383 | }); 384 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 385 | 386 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 387 | 388 | expect(result.version).toBe('2'); 389 | }); 390 | 391 | it('should handle version as string in description', () => { 392 | const nodeDefinition = programmaticNodeFactory.build({ 393 | version: '1.5' as any 394 | }); 395 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 396 | 397 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 398 | 399 | expect(result.version).toBe('1.5'); 400 | }); 401 | 402 | it('should default to version 1 when no version found', () => { 403 | const nodeDefinition = programmaticNodeFactory.build(); 404 | delete (nodeDefinition as any).version; 405 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 406 | 407 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 408 | 409 | expect(result.version).toBe('1'); 410 | }); 411 | }); 412 | 413 | describe('versioned node detection', () => { 414 | it('should detect versioned nodes with nodeVersions', () => { 415 | const NodeClass = class { 416 | description = { name: 'test', displayName: 'Test' }; 417 | nodeVersions = { 1: {}, 2: {} }; 418 | }; 419 | 420 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 421 | 422 | expect(result.isVersioned).toBe(true); 423 | }); 424 | 425 | it('should detect versioned nodes with defaultVersion', () => { 426 | const NodeClass = class { 427 | baseDescription = { 428 | name: 'test', 429 | displayName: 'Test', 430 | defaultVersion: 2 431 | }; 432 | }; 433 | 434 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 435 | 436 | expect(result.isVersioned).toBe(true); 437 | }); 438 | 439 | it('should detect versioned nodes with version array in instance', () => { 440 | const NodeClass = class { 441 | description = { 442 | name: 'test', 443 | displayName: 'Test', 444 | version: [1, 1.1, 2] 445 | }; 446 | }; 447 | 448 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 449 | 450 | expect(result.isVersioned).toBe(true); 451 | }); 452 | 453 | it('should not detect non-versioned nodes as versioned', () => { 454 | const nodeDefinition = programmaticNodeFactory.build({ 455 | version: 1 456 | }); 457 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 458 | 459 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 460 | 461 | expect(result.isVersioned).toBe(false); 462 | }); 463 | }); 464 | 465 | describe('edge cases', () => { 466 | it('should handle null/undefined description gracefully', () => { 467 | const NodeClass = class { 468 | description = null; 469 | }; 470 | 471 | expect(() => parser.parse(NodeClass as any, 'n8n-nodes-base')).toThrow(); 472 | }); 473 | 474 | it('should handle empty routing object for declarative nodes', () => { 475 | const nodeDefinition = declarativeNodeFactory.build({ 476 | routing: {} as any 477 | }); 478 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 479 | 480 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 481 | 482 | expect(result.style).toBe('declarative'); 483 | }); 484 | 485 | it('should handle complex nested versioned structure', () => { 486 | const NodeClass = class VersionedNodeType { 487 | constructor() { 488 | (this as any).baseDescription = { 489 | name: 'complex', 490 | displayName: 'Complex Node', 491 | defaultVersion: 3 492 | }; 493 | (this as any).nodeVersions = { 494 | 1: { description: { properties: [] } }, 495 | 2: { description: { properties: [] } }, 496 | 3: { description: { properties: [] } } 497 | }; 498 | } 499 | }; 500 | 501 | // Override constructor name check 502 | Object.defineProperty(NodeClass.prototype.constructor, 'name', { 503 | value: 'VersionedNodeType' 504 | }); 505 | 506 | const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); 507 | 508 | expect(result.isVersioned).toBe(true); 509 | expect(result.version).toBe('3'); 510 | }); 511 | }); 512 | }); ``` -------------------------------------------------------------------------------- /src/services/node-similarity-service.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { NodeRepository } from '../database/node-repository'; 2 | import { logger } from '../utils/logger'; 3 | 4 | export interface NodeSuggestion { 5 | nodeType: string; 6 | displayName: string; 7 | confidence: number; 8 | reason: string; 9 | category?: string; 10 | description?: string; 11 | } 12 | 13 | export interface SimilarityScore { 14 | nameSimilarity: number; 15 | categoryMatch: number; 16 | packageMatch: number; 17 | patternMatch: number; 18 | totalScore: number; 19 | } 20 | 21 | export interface CommonMistakePattern { 22 | pattern: string; 23 | suggestion: string; 24 | confidence: number; 25 | reason: string; 26 | } 27 | 28 | export class NodeSimilarityService { 29 | // Constants to avoid magic numbers 30 | private static readonly SCORING_THRESHOLD = 50; // Minimum 50% confidence to suggest 31 | private static readonly TYPO_EDIT_DISTANCE = 2; // Max 2 character differences for typo detection 32 | private static readonly SHORT_SEARCH_LENGTH = 5; // Searches ≤5 chars need special handling 33 | private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes 34 | private static readonly AUTO_FIX_CONFIDENCE = 0.9; // 90% confidence for auto-fix 35 | 36 | private repository: NodeRepository; 37 | private commonMistakes: Map<string, CommonMistakePattern[]>; 38 | private nodeCache: any[] | null = null; 39 | private cacheExpiry: number = 0; 40 | private cacheVersion: number = 0; // Track cache version for invalidation 41 | 42 | constructor(repository: NodeRepository) { 43 | this.repository = repository; 44 | this.commonMistakes = this.initializeCommonMistakes(); 45 | } 46 | 47 | /** 48 | * Initialize common mistake patterns 49 | * Using safer string-based patterns instead of complex regex to avoid ReDoS 50 | */ 51 | private initializeCommonMistakes(): Map<string, CommonMistakePattern[]> { 52 | const patterns = new Map<string, CommonMistakePattern[]>(); 53 | 54 | // Case variations - using exact string matching (case-insensitive) 55 | patterns.set('case_variations', [ 56 | { pattern: 'httprequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Incorrect capitalization' }, 57 | { pattern: 'webhook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Incorrect capitalization' }, 58 | { pattern: 'slack', suggestion: 'nodes-base.slack', confidence: 0.9, reason: 'Missing package prefix' }, 59 | { pattern: 'gmail', suggestion: 'nodes-base.gmail', confidence: 0.9, reason: 'Missing package prefix' }, 60 | { pattern: 'googlesheets', suggestion: 'nodes-base.googleSheets', confidence: 0.9, reason: 'Missing package prefix' }, 61 | { pattern: 'telegram', suggestion: 'nodes-base.telegram', confidence: 0.9, reason: 'Missing package prefix' }, 62 | ]); 63 | 64 | // Specific case variations that are common 65 | patterns.set('specific_variations', [ 66 | { pattern: 'HttpRequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Incorrect capitalization' }, 67 | { pattern: 'HTTPRequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Common capitalization mistake' }, 68 | { pattern: 'Webhook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Incorrect capitalization' }, 69 | { pattern: 'WebHook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Common capitalization mistake' }, 70 | ]); 71 | 72 | // Deprecated package prefixes 73 | patterns.set('deprecated_prefixes', [ 74 | { pattern: 'n8n-nodes-base.', suggestion: 'nodes-base.', confidence: 0.95, reason: 'Full package name used instead of short form' }, 75 | { pattern: '@n8n/n8n-nodes-langchain.', suggestion: 'nodes-langchain.', confidence: 0.95, reason: 'Full package name used instead of short form' }, 76 | ]); 77 | 78 | // Common typos - exact matches 79 | patterns.set('typos', [ 80 | { pattern: 'htprequest', suggestion: 'nodes-base.httpRequest', confidence: 0.8, reason: 'Likely typo' }, 81 | { pattern: 'httpreqest', suggestion: 'nodes-base.httpRequest', confidence: 0.8, reason: 'Likely typo' }, 82 | { pattern: 'webook', suggestion: 'nodes-base.webhook', confidence: 0.8, reason: 'Likely typo' }, 83 | { pattern: 'slak', suggestion: 'nodes-base.slack', confidence: 0.8, reason: 'Likely typo' }, 84 | { pattern: 'googlesheets', suggestion: 'nodes-base.googleSheets', confidence: 0.8, reason: 'Likely typo' }, 85 | ]); 86 | 87 | // AI/LangChain specific 88 | patterns.set('ai_nodes', [ 89 | { pattern: 'openai', suggestion: 'nodes-langchain.openAi', confidence: 0.85, reason: 'AI node - incorrect package' }, 90 | { pattern: 'nodes-base.openai', suggestion: 'nodes-langchain.openAi', confidence: 0.9, reason: 'Wrong package - OpenAI is in LangChain package' }, 91 | { pattern: 'chatopenai', suggestion: 'nodes-langchain.lmChatOpenAi', confidence: 0.85, reason: 'LangChain node naming convention' }, 92 | { pattern: 'vectorstore', suggestion: 'nodes-langchain.vectorStoreInMemory', confidence: 0.7, reason: 'Generic vector store reference' }, 93 | ]); 94 | 95 | return patterns; 96 | } 97 | 98 | /** 99 | * Check if a type is a common node name without prefix 100 | */ 101 | private isCommonNodeWithoutPrefix(type: string): string | null { 102 | const commonNodes: Record<string, string> = { 103 | 'httprequest': 'nodes-base.httpRequest', 104 | 'webhook': 'nodes-base.webhook', 105 | 'slack': 'nodes-base.slack', 106 | 'gmail': 'nodes-base.gmail', 107 | 'googlesheets': 'nodes-base.googleSheets', 108 | 'telegram': 'nodes-base.telegram', 109 | 'discord': 'nodes-base.discord', 110 | 'notion': 'nodes-base.notion', 111 | 'airtable': 'nodes-base.airtable', 112 | 'postgres': 'nodes-base.postgres', 113 | 'mysql': 'nodes-base.mySql', 114 | 'mongodb': 'nodes-base.mongoDb', 115 | }; 116 | 117 | const normalized = type.toLowerCase(); 118 | return commonNodes[normalized] || null; 119 | } 120 | 121 | /** 122 | * Find similar nodes for an invalid type 123 | */ 124 | async findSimilarNodes(invalidType: string, limit: number = 5): Promise<NodeSuggestion[]> { 125 | if (!invalidType || invalidType.trim() === '') { 126 | return []; 127 | } 128 | 129 | const suggestions: NodeSuggestion[] = []; 130 | 131 | // First, check for exact common mistakes 132 | const mistakeSuggestion = this.checkCommonMistakes(invalidType); 133 | if (mistakeSuggestion) { 134 | suggestions.push(mistakeSuggestion); 135 | } 136 | 137 | // Get all nodes (with caching) 138 | const allNodes = await this.getCachedNodes(); 139 | 140 | // Calculate similarity scores for all nodes 141 | const scores = allNodes.map(node => ({ 142 | node, 143 | score: this.calculateSimilarityScore(invalidType, node) 144 | })); 145 | 146 | // Sort by total score and filter high scores 147 | scores.sort((a, b) => b.score.totalScore - a.score.totalScore); 148 | 149 | // Add top suggestions (excluding already added exact matches) 150 | for (const { node, score } of scores) { 151 | if (suggestions.some(s => s.nodeType === node.nodeType)) { 152 | continue; 153 | } 154 | 155 | if (score.totalScore >= NodeSimilarityService.SCORING_THRESHOLD) { 156 | suggestions.push(this.createSuggestion(node, score)); 157 | } 158 | 159 | if (suggestions.length >= limit) { 160 | break; 161 | } 162 | } 163 | 164 | return suggestions; 165 | } 166 | 167 | /** 168 | * Check for common mistake patterns (ReDoS-safe implementation) 169 | */ 170 | private checkCommonMistakes(invalidType: string): NodeSuggestion | null { 171 | const cleanType = invalidType.trim(); 172 | const lowerType = cleanType.toLowerCase(); 173 | 174 | // First check for common nodes without prefix 175 | const commonNodeSuggestion = this.isCommonNodeWithoutPrefix(cleanType); 176 | if (commonNodeSuggestion) { 177 | const node = this.repository.getNode(commonNodeSuggestion); 178 | if (node) { 179 | return { 180 | nodeType: commonNodeSuggestion, 181 | displayName: node.displayName, 182 | confidence: 0.9, 183 | reason: 'Missing package prefix', 184 | category: node.category, 185 | description: node.description 186 | }; 187 | } 188 | } 189 | 190 | // Check deprecated prefixes (string-based, no regex) 191 | for (const [category, patterns] of this.commonMistakes) { 192 | if (category === 'deprecated_prefixes') { 193 | for (const pattern of patterns) { 194 | if (cleanType.startsWith(pattern.pattern)) { 195 | const actualSuggestion = cleanType.replace(pattern.pattern, pattern.suggestion); 196 | const node = this.repository.getNode(actualSuggestion); 197 | if (node) { 198 | return { 199 | nodeType: actualSuggestion, 200 | displayName: node.displayName, 201 | confidence: pattern.confidence, 202 | reason: pattern.reason, 203 | category: node.category, 204 | description: node.description 205 | }; 206 | } 207 | } 208 | } 209 | } 210 | } 211 | 212 | // Check exact matches for typos and variations 213 | for (const [category, patterns] of this.commonMistakes) { 214 | if (category === 'deprecated_prefixes') continue; // Already handled 215 | 216 | for (const pattern of patterns) { 217 | // Simple string comparison (case-sensitive for specific_variations) 218 | const match = category === 'specific_variations' 219 | ? cleanType === pattern.pattern 220 | : lowerType === pattern.pattern.toLowerCase(); 221 | 222 | if (match && pattern.suggestion) { 223 | const node = this.repository.getNode(pattern.suggestion); 224 | if (node) { 225 | return { 226 | nodeType: pattern.suggestion, 227 | displayName: node.displayName, 228 | confidence: pattern.confidence, 229 | reason: pattern.reason, 230 | category: node.category, 231 | description: node.description 232 | }; 233 | } 234 | } 235 | } 236 | } 237 | 238 | return null; 239 | } 240 | 241 | /** 242 | * Calculate multi-factor similarity score 243 | */ 244 | private calculateSimilarityScore(invalidType: string, node: any): SimilarityScore { 245 | const cleanInvalid = this.normalizeNodeType(invalidType); 246 | const cleanValid = this.normalizeNodeType(node.nodeType); 247 | const displayNameClean = this.normalizeNodeType(node.displayName); 248 | 249 | // Special handling for very short search terms (e.g., "http", "sheet") 250 | const isShortSearch = invalidType.length <= NodeSimilarityService.SHORT_SEARCH_LENGTH; 251 | 252 | // Name similarity (40% weight) 253 | let nameSimilarity = Math.max( 254 | this.getStringSimilarity(cleanInvalid, cleanValid), 255 | this.getStringSimilarity(cleanInvalid, displayNameClean) 256 | ) * 40; 257 | 258 | // For short searches that are substrings, give a small name similarity boost 259 | if (isShortSearch && (cleanValid.includes(cleanInvalid) || displayNameClean.includes(cleanInvalid))) { 260 | nameSimilarity = Math.max(nameSimilarity, 10); 261 | } 262 | 263 | // Category match (20% weight) 264 | let categoryMatch = 0; 265 | if (node.category) { 266 | const categoryClean = this.normalizeNodeType(node.category); 267 | if (cleanInvalid.includes(categoryClean) || categoryClean.includes(cleanInvalid)) { 268 | categoryMatch = 20; 269 | } 270 | } 271 | 272 | // Package match (15% weight) 273 | let packageMatch = 0; 274 | const invalidParts = cleanInvalid.split(/[.-]/); 275 | const validParts = cleanValid.split(/[.-]/); 276 | 277 | if (invalidParts[0] === validParts[0]) { 278 | packageMatch = 15; 279 | } 280 | 281 | // Pattern match (25% weight) 282 | let patternMatch = 0; 283 | 284 | // Check if it's a substring match 285 | if (cleanValid.includes(cleanInvalid) || displayNameClean.includes(cleanInvalid)) { 286 | // Boost score significantly for short searches that are exact substring matches 287 | // Short searches need more boost to reach the 50 threshold 288 | patternMatch = isShortSearch ? 45 : 25; 289 | } else if (this.getEditDistance(cleanInvalid, cleanValid) <= NodeSimilarityService.TYPO_EDIT_DISTANCE) { 290 | // Small edit distance indicates likely typo 291 | patternMatch = 20; 292 | } else if (this.getEditDistance(cleanInvalid, displayNameClean) <= NodeSimilarityService.TYPO_EDIT_DISTANCE) { 293 | patternMatch = 18; 294 | } 295 | 296 | // For very short searches, also check if the search term appears at the start 297 | if (isShortSearch && (cleanValid.startsWith(cleanInvalid) || displayNameClean.startsWith(cleanInvalid))) { 298 | patternMatch = Math.max(patternMatch, 40); 299 | } 300 | 301 | const totalScore = nameSimilarity + categoryMatch + packageMatch + patternMatch; 302 | 303 | return { 304 | nameSimilarity, 305 | categoryMatch, 306 | packageMatch, 307 | patternMatch, 308 | totalScore 309 | }; 310 | } 311 | 312 | /** 313 | * Create a suggestion object from node and score 314 | */ 315 | private createSuggestion(node: any, score: SimilarityScore): NodeSuggestion { 316 | let reason = 'Similar node'; 317 | 318 | if (score.patternMatch >= 20) { 319 | reason = 'Name similarity'; 320 | } else if (score.categoryMatch >= 15) { 321 | reason = 'Same category'; 322 | } else if (score.packageMatch >= 10) { 323 | reason = 'Same package'; 324 | } 325 | 326 | // Calculate confidence (0-1 scale) 327 | const confidence = Math.min(score.totalScore / 100, 1); 328 | 329 | return { 330 | nodeType: node.nodeType, 331 | displayName: node.displayName, 332 | confidence, 333 | reason, 334 | category: node.category, 335 | description: node.description 336 | }; 337 | } 338 | 339 | /** 340 | * Normalize node type for comparison 341 | */ 342 | private normalizeNodeType(type: string): string { 343 | return type 344 | .toLowerCase() 345 | .replace(/[^a-z0-9]/g, '') 346 | .trim(); 347 | } 348 | 349 | /** 350 | * Calculate string similarity (0-1) 351 | */ 352 | private getStringSimilarity(s1: string, s2: string): number { 353 | if (s1 === s2) return 1; 354 | if (!s1 || !s2) return 0; 355 | 356 | const distance = this.getEditDistance(s1, s2); 357 | const maxLen = Math.max(s1.length, s2.length); 358 | 359 | return 1 - (distance / maxLen); 360 | } 361 | 362 | /** 363 | * Calculate Levenshtein distance with optimizations 364 | * - Early termination when difference exceeds threshold 365 | * - Space-optimized to use only two rows instead of full matrix 366 | * - Fast path for identical or vastly different strings 367 | */ 368 | private getEditDistance(s1: string, s2: string, maxDistance: number = 5): number { 369 | // Fast path: identical strings 370 | if (s1 === s2) return 0; 371 | 372 | const m = s1.length; 373 | const n = s2.length; 374 | 375 | // Fast path: length difference exceeds threshold 376 | const lengthDiff = Math.abs(m - n); 377 | if (lengthDiff > maxDistance) return maxDistance + 1; 378 | 379 | // Fast path: empty strings 380 | if (m === 0) return n; 381 | if (n === 0) return m; 382 | 383 | // Space optimization: only need previous and current row 384 | let prev = Array(n + 1).fill(0).map((_, i) => i); 385 | 386 | for (let i = 1; i <= m; i++) { 387 | const curr = [i]; 388 | let minInRow = i; 389 | 390 | for (let j = 1; j <= n; j++) { 391 | const cost = s1[i - 1] === s2[j - 1] ? 0 : 1; 392 | const val = Math.min( 393 | curr[j - 1] + 1, // deletion 394 | prev[j] + 1, // insertion 395 | prev[j - 1] + cost // substitution 396 | ); 397 | curr.push(val); 398 | minInRow = Math.min(minInRow, val); 399 | } 400 | 401 | // Early termination: if minimum in this row exceeds threshold 402 | if (minInRow > maxDistance) { 403 | return maxDistance + 1; 404 | } 405 | 406 | prev = curr; 407 | } 408 | 409 | return prev[n]; 410 | } 411 | 412 | /** 413 | * Get cached nodes or fetch from repository 414 | * Implements proper cache invalidation with version tracking 415 | */ 416 | private async getCachedNodes(): Promise<any[]> { 417 | const now = Date.now(); 418 | 419 | if (!this.nodeCache || now > this.cacheExpiry) { 420 | try { 421 | const newNodes = this.repository.getAllNodes(); 422 | 423 | // Only update cache if we got valid data 424 | if (newNodes && newNodes.length > 0) { 425 | this.nodeCache = newNodes; 426 | this.cacheExpiry = now + NodeSimilarityService.CACHE_DURATION_MS; 427 | this.cacheVersion++; 428 | logger.debug('Node cache refreshed', { 429 | count: newNodes.length, 430 | version: this.cacheVersion 431 | }); 432 | } else if (this.nodeCache) { 433 | // Return stale cache if new fetch returned empty 434 | logger.warn('Node fetch returned empty, using stale cache'); 435 | } 436 | } catch (error) { 437 | logger.error('Failed to fetch nodes for similarity service', error); 438 | // Return stale cache on error if available 439 | if (this.nodeCache) { 440 | logger.info('Using stale cache due to fetch error'); 441 | return this.nodeCache; 442 | } 443 | return []; 444 | } 445 | } 446 | 447 | return this.nodeCache || []; 448 | } 449 | 450 | /** 451 | * Invalidate the cache (e.g., after database updates) 452 | */ 453 | public invalidateCache(): void { 454 | this.nodeCache = null; 455 | this.cacheExpiry = 0; 456 | this.cacheVersion++; 457 | logger.debug('Node cache invalidated', { version: this.cacheVersion }); 458 | } 459 | 460 | /** 461 | * Clear and refresh cache immediately 462 | */ 463 | public async refreshCache(): Promise<void> { 464 | this.invalidateCache(); 465 | await this.getCachedNodes(); 466 | } 467 | 468 | /** 469 | * Format suggestions into a user-friendly message 470 | */ 471 | formatSuggestionMessage(suggestions: NodeSuggestion[], invalidType: string): string { 472 | if (suggestions.length === 0) { 473 | return `Unknown node type: "${invalidType}". No similar nodes found.`; 474 | } 475 | 476 | let message = `Unknown node type: "${invalidType}"\n\nDid you mean one of these?\n`; 477 | 478 | for (const suggestion of suggestions) { 479 | const confidence = Math.round(suggestion.confidence * 100); 480 | message += `• ${suggestion.nodeType} (${confidence}% match)`; 481 | 482 | if (suggestion.displayName) { 483 | message += ` - ${suggestion.displayName}`; 484 | } 485 | 486 | message += `\n → ${suggestion.reason}`; 487 | 488 | if (suggestion.confidence >= 0.9) { 489 | message += ' (can be auto-fixed)'; 490 | } 491 | 492 | message += '\n'; 493 | } 494 | 495 | return message; 496 | } 497 | 498 | /** 499 | * Check if a suggestion is high confidence for auto-fixing 500 | */ 501 | isAutoFixable(suggestion: NodeSuggestion): boolean { 502 | return suggestion.confidence >= NodeSimilarityService.AUTO_FIX_CONFIDENCE; 503 | } 504 | 505 | /** 506 | * Clear the node cache (useful after database updates) 507 | * @deprecated Use invalidateCache() instead for proper version tracking 508 | */ 509 | clearCache(): void { 510 | this.invalidateCache(); 511 | } 512 | } ``` -------------------------------------------------------------------------------- /tests/unit/telemetry/event-validator.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { z } from 'zod'; 3 | import { TelemetryEventValidator, telemetryEventSchema, workflowTelemetrySchema } from '../../../src/telemetry/event-validator'; 4 | import { TelemetryEvent, WorkflowTelemetry } from '../../../src/telemetry/telemetry-types'; 5 | 6 | // Mock logger to avoid console output in tests 7 | vi.mock('../../../src/utils/logger', () => ({ 8 | logger: { 9 | debug: vi.fn(), 10 | info: vi.fn(), 11 | warn: vi.fn(), 12 | error: vi.fn(), 13 | } 14 | })); 15 | 16 | describe('TelemetryEventValidator', () => { 17 | let validator: TelemetryEventValidator; 18 | 19 | beforeEach(() => { 20 | validator = new TelemetryEventValidator(); 21 | vi.clearAllMocks(); 22 | }); 23 | 24 | describe('validateEvent()', () => { 25 | it('should validate a basic valid event', () => { 26 | const event: TelemetryEvent = { 27 | user_id: 'user123', 28 | event: 'tool_used', 29 | properties: { tool: 'httpRequest', success: true, duration: 500 } 30 | }; 31 | 32 | const result = validator.validateEvent(event); 33 | expect(result).toEqual(event); 34 | }); 35 | 36 | it('should validate event with specific schema for tool_used', () => { 37 | const event: TelemetryEvent = { 38 | user_id: 'user123', 39 | event: 'tool_used', 40 | properties: { tool: 'httpRequest', success: true, duration: 500 } 41 | }; 42 | 43 | const result = validator.validateEvent(event); 44 | expect(result).not.toBeNull(); 45 | expect(result?.properties.tool).toBe('httpRequest'); 46 | expect(result?.properties.success).toBe(true); 47 | expect(result?.properties.duration).toBe(500); 48 | }); 49 | 50 | it('should validate search_query event with specific schema', () => { 51 | const event: TelemetryEvent = { 52 | user_id: 'user123', 53 | event: 'search_query', 54 | properties: { 55 | query: 'test query', 56 | resultsFound: 5, 57 | searchType: 'nodes', 58 | hasResults: true, 59 | isZeroResults: false 60 | } 61 | }; 62 | 63 | const result = validator.validateEvent(event); 64 | expect(result).not.toBeNull(); 65 | expect(result?.properties.query).toBe('test query'); 66 | expect(result?.properties.resultsFound).toBe(5); 67 | expect(result?.properties.hasResults).toBe(true); 68 | }); 69 | 70 | it('should validate performance_metric event with specific schema', () => { 71 | const event: TelemetryEvent = { 72 | user_id: 'user123', 73 | event: 'performance_metric', 74 | properties: { 75 | operation: 'database_query', 76 | duration: 1500, 77 | isSlow: true, 78 | isVerySlow: false, 79 | metadata: { table: 'nodes' } 80 | } 81 | }; 82 | 83 | const result = validator.validateEvent(event); 84 | expect(result).not.toBeNull(); 85 | expect(result?.properties.operation).toBe('database_query'); 86 | expect(result?.properties.duration).toBe(1500); 87 | expect(result?.properties.isSlow).toBe(true); 88 | }); 89 | 90 | it('should sanitize sensitive data from properties', () => { 91 | const event: TelemetryEvent = { 92 | user_id: 'user123', 93 | event: 'generic_event', 94 | properties: { 95 | description: 'Visit https://example.com/secret and [email protected] with key abcdef123456789012345678901234567890', 96 | apiKey: 'super-secret-key-12345678901234567890', 97 | normalProp: 'normal value' 98 | } 99 | }; 100 | 101 | const result = validator.validateEvent(event); 102 | expect(result).not.toBeNull(); 103 | expect(result?.properties.description).toBe('Visit [URL] and [EMAIL] with key [KEY]'); 104 | expect(result?.properties.normalProp).toBe('normal value'); 105 | expect(result?.properties).not.toHaveProperty('apiKey'); // Should be filtered out 106 | }); 107 | 108 | it('should handle nested object sanitization with depth limit', () => { 109 | const event: TelemetryEvent = { 110 | user_id: 'user123', 111 | event: 'nested_event', 112 | properties: { 113 | nested: { 114 | level1: { 115 | level2: { 116 | level3: { 117 | level4: 'should be truncated', 118 | apiKey: 'secret123', 119 | description: 'Visit https://example.com' 120 | }, 121 | description: 'Visit https://another.com' 122 | } 123 | } 124 | } 125 | } 126 | }; 127 | 128 | const result = validator.validateEvent(event); 129 | expect(result).not.toBeNull(); 130 | expect(result?.properties.nested.level1.level2.level3).toBe('[NESTED]'); 131 | expect(result?.properties.nested.level1.level2.description).toBe('Visit [URL]'); 132 | }); 133 | 134 | it('should handle array sanitization with size limit', () => { 135 | const event: TelemetryEvent = { 136 | user_id: 'user123', 137 | event: 'array_event', 138 | properties: { 139 | items: Array.from({ length: 15 }, (_, i) => ({ 140 | id: i, 141 | description: 'Visit https://example.com', 142 | value: `item-${i}` 143 | })) 144 | } 145 | }; 146 | 147 | const result = validator.validateEvent(event); 148 | expect(result).not.toBeNull(); 149 | expect(Array.isArray(result?.properties.items)).toBe(true); 150 | expect(result?.properties.items.length).toBe(10); // Should be limited to 10 151 | }); 152 | 153 | it('should reject events with invalid user_id', () => { 154 | const event: TelemetryEvent = { 155 | user_id: '', // Empty string 156 | event: 'test_event', 157 | properties: {} 158 | }; 159 | 160 | const result = validator.validateEvent(event); 161 | expect(result).toBeNull(); 162 | }); 163 | 164 | it('should reject events with invalid event name', () => { 165 | const event: TelemetryEvent = { 166 | user_id: 'user123', 167 | event: 'invalid-event-name!@#', // Invalid characters 168 | properties: {} 169 | }; 170 | 171 | const result = validator.validateEvent(event); 172 | expect(result).toBeNull(); 173 | }); 174 | 175 | it('should reject tool_used event with invalid properties', () => { 176 | const event: TelemetryEvent = { 177 | user_id: 'user123', 178 | event: 'tool_used', 179 | properties: { 180 | tool: 'test', 181 | success: 'not-a-boolean', // Should be boolean 182 | duration: -1 // Should be positive 183 | } 184 | }; 185 | 186 | const result = validator.validateEvent(event); 187 | expect(result).toBeNull(); 188 | }); 189 | 190 | it('should filter out sensitive keys from properties', () => { 191 | const event: TelemetryEvent = { 192 | user_id: 'user123', 193 | event: 'sensitive_event', 194 | properties: { 195 | password: 'secret123', 196 | token: 'bearer-token', 197 | apikey: 'api-key-value', 198 | secret: 'secret-value', 199 | credential: 'cred-value', 200 | auth: 'auth-header', 201 | url: 'https://example.com', 202 | endpoint: 'api.example.com', 203 | host: 'localhost', 204 | database: 'prod-db', 205 | normalProp: 'safe-value', 206 | count: 42, 207 | enabled: true 208 | } 209 | }; 210 | 211 | const result = validator.validateEvent(event); 212 | expect(result).not.toBeNull(); 213 | expect(result?.properties).not.toHaveProperty('password'); 214 | expect(result?.properties).not.toHaveProperty('token'); 215 | expect(result?.properties).not.toHaveProperty('apikey'); 216 | expect(result?.properties).not.toHaveProperty('secret'); 217 | expect(result?.properties).not.toHaveProperty('credential'); 218 | expect(result?.properties).not.toHaveProperty('auth'); 219 | expect(result?.properties).not.toHaveProperty('url'); 220 | expect(result?.properties).not.toHaveProperty('endpoint'); 221 | expect(result?.properties).not.toHaveProperty('host'); 222 | expect(result?.properties).not.toHaveProperty('database'); 223 | expect(result?.properties.normalProp).toBe('safe-value'); 224 | expect(result?.properties.count).toBe(42); 225 | expect(result?.properties.enabled).toBe(true); 226 | }); 227 | 228 | it('should handle validation_details event schema', () => { 229 | const event: TelemetryEvent = { 230 | user_id: 'user123', 231 | event: 'validation_details', 232 | properties: { 233 | nodeType: 'nodes-base.httpRequest', 234 | errorType: 'required_field_missing', 235 | errorCategory: 'validation_error', 236 | details: { field: 'url' } 237 | } 238 | }; 239 | 240 | const result = validator.validateEvent(event); 241 | expect(result).not.toBeNull(); 242 | expect(result?.properties.nodeType).toBe('nodes-base.httpRequest'); 243 | expect(result?.properties.errorType).toBe('required_field_missing'); 244 | }); 245 | 246 | it('should handle null and undefined values', () => { 247 | const event: TelemetryEvent = { 248 | user_id: 'user123', 249 | event: 'null_event', 250 | properties: { 251 | nullValue: null, 252 | undefinedValue: undefined, 253 | normalValue: 'test' 254 | } 255 | }; 256 | 257 | const result = validator.validateEvent(event); 258 | expect(result).not.toBeNull(); 259 | expect(result?.properties.nullValue).toBeNull(); 260 | expect(result?.properties.undefinedValue).toBeNull(); 261 | expect(result?.properties.normalValue).toBe('test'); 262 | }); 263 | }); 264 | 265 | describe('validateWorkflow()', () => { 266 | it('should validate a valid workflow', () => { 267 | const workflow: WorkflowTelemetry = { 268 | user_id: 'user123', 269 | workflow_hash: 'hash123', 270 | node_count: 3, 271 | node_types: ['webhook', 'httpRequest', 'set'], 272 | has_trigger: true, 273 | has_webhook: true, 274 | complexity: 'medium', 275 | sanitized_workflow: { 276 | nodes: [ 277 | { id: '1', type: 'webhook' }, 278 | { id: '2', type: 'httpRequest' }, 279 | { id: '3', type: 'set' } 280 | ], 281 | connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } } 282 | } 283 | }; 284 | 285 | const result = validator.validateWorkflow(workflow); 286 | expect(result).toEqual(workflow); 287 | }); 288 | 289 | it('should reject workflow with too many nodes', () => { 290 | const workflow: WorkflowTelemetry = { 291 | user_id: 'user123', 292 | workflow_hash: 'hash123', 293 | node_count: 1001, // Over limit 294 | node_types: ['webhook'], 295 | has_trigger: true, 296 | has_webhook: true, 297 | complexity: 'complex', 298 | sanitized_workflow: { 299 | nodes: [], 300 | connections: {} 301 | } 302 | }; 303 | 304 | const result = validator.validateWorkflow(workflow); 305 | expect(result).toBeNull(); 306 | }); 307 | 308 | it('should reject workflow with invalid complexity', () => { 309 | const workflow = { 310 | user_id: 'user123', 311 | workflow_hash: 'hash123', 312 | node_count: 3, 313 | node_types: ['webhook'], 314 | has_trigger: true, 315 | has_webhook: true, 316 | complexity: 'invalid' as any, // Invalid complexity 317 | sanitized_workflow: { 318 | nodes: [], 319 | connections: {} 320 | } 321 | }; 322 | 323 | const result = validator.validateWorkflow(workflow); 324 | expect(result).toBeNull(); 325 | }); 326 | 327 | it('should reject workflow with too many node types', () => { 328 | const workflow: WorkflowTelemetry = { 329 | user_id: 'user123', 330 | workflow_hash: 'hash123', 331 | node_count: 3, 332 | node_types: Array.from({ length: 101 }, (_, i) => `node-${i}`), // Over limit 333 | has_trigger: true, 334 | has_webhook: true, 335 | complexity: 'complex', 336 | sanitized_workflow: { 337 | nodes: [], 338 | connections: {} 339 | } 340 | }; 341 | 342 | const result = validator.validateWorkflow(workflow); 343 | expect(result).toBeNull(); 344 | }); 345 | }); 346 | 347 | describe('getStats()', () => { 348 | it('should track validation statistics', () => { 349 | const validEvent: TelemetryEvent = { 350 | user_id: 'user123', 351 | event: 'valid_event', 352 | properties: {} 353 | }; 354 | 355 | const invalidEvent: TelemetryEvent = { 356 | user_id: '', // Invalid 357 | event: 'invalid_event', 358 | properties: {} 359 | }; 360 | 361 | validator.validateEvent(validEvent); 362 | validator.validateEvent(validEvent); 363 | validator.validateEvent(invalidEvent); 364 | 365 | const stats = validator.getStats(); 366 | expect(stats.successes).toBe(2); 367 | expect(stats.errors).toBe(1); 368 | expect(stats.total).toBe(3); 369 | expect(stats.errorRate).toBeCloseTo(0.333, 3); 370 | }); 371 | 372 | it('should handle division by zero in error rate', () => { 373 | const stats = validator.getStats(); 374 | expect(stats.errorRate).toBe(0); 375 | }); 376 | }); 377 | 378 | describe('resetStats()', () => { 379 | it('should reset validation statistics', () => { 380 | const validEvent: TelemetryEvent = { 381 | user_id: 'user123', 382 | event: 'valid_event', 383 | properties: {} 384 | }; 385 | 386 | validator.validateEvent(validEvent); 387 | validator.resetStats(); 388 | 389 | const stats = validator.getStats(); 390 | expect(stats.successes).toBe(0); 391 | expect(stats.errors).toBe(0); 392 | expect(stats.total).toBe(0); 393 | expect(stats.errorRate).toBe(0); 394 | }); 395 | }); 396 | 397 | describe('Schema validation', () => { 398 | describe('telemetryEventSchema', () => { 399 | it('should validate with created_at timestamp', () => { 400 | const event = { 401 | user_id: 'user123', 402 | event: 'test_event', 403 | properties: {}, 404 | created_at: '2024-01-01T00:00:00Z' 405 | }; 406 | 407 | const result = telemetryEventSchema.safeParse(event); 408 | expect(result.success).toBe(true); 409 | }); 410 | 411 | it('should reject invalid datetime format', () => { 412 | const event = { 413 | user_id: 'user123', 414 | event: 'test_event', 415 | properties: {}, 416 | created_at: 'invalid-date' 417 | }; 418 | 419 | const result = telemetryEventSchema.safeParse(event); 420 | expect(result.success).toBe(false); 421 | }); 422 | 423 | it('should enforce user_id length limits', () => { 424 | const longUserId = 'a'.repeat(65); 425 | const event = { 426 | user_id: longUserId, 427 | event: 'test_event', 428 | properties: {} 429 | }; 430 | 431 | const result = telemetryEventSchema.safeParse(event); 432 | expect(result.success).toBe(false); 433 | }); 434 | 435 | it('should enforce event name regex pattern', () => { 436 | const event = { 437 | user_id: 'user123', 438 | event: 'invalid event name with spaces!', 439 | properties: {} 440 | }; 441 | 442 | const result = telemetryEventSchema.safeParse(event); 443 | expect(result.success).toBe(false); 444 | }); 445 | }); 446 | 447 | describe('workflowTelemetrySchema', () => { 448 | it('should enforce node array size limits', () => { 449 | const workflow = { 450 | user_id: 'user123', 451 | workflow_hash: 'hash123', 452 | node_count: 3, 453 | node_types: ['test'], 454 | has_trigger: true, 455 | has_webhook: false, 456 | complexity: 'simple', 457 | sanitized_workflow: { 458 | nodes: Array.from({ length: 1001 }, (_, i) => ({ id: i })), // Over limit 459 | connections: {} 460 | } 461 | }; 462 | 463 | const result = workflowTelemetrySchema.safeParse(workflow); 464 | expect(result.success).toBe(false); 465 | }); 466 | 467 | it('should validate with optional created_at', () => { 468 | const workflow = { 469 | user_id: 'user123', 470 | workflow_hash: 'hash123', 471 | node_count: 1, 472 | node_types: ['webhook'], 473 | has_trigger: true, 474 | has_webhook: true, 475 | complexity: 'simple', 476 | sanitized_workflow: { 477 | nodes: [{ id: '1' }], 478 | connections: {} 479 | }, 480 | created_at: '2024-01-01T00:00:00Z' 481 | }; 482 | 483 | const result = workflowTelemetrySchema.safeParse(workflow); 484 | expect(result.success).toBe(true); 485 | }); 486 | }); 487 | }); 488 | 489 | describe('String sanitization edge cases', () => { 490 | it('should handle multiple URLs in same string', () => { 491 | const event: TelemetryEvent = { 492 | user_id: 'user123', 493 | event: 'test_event', 494 | properties: { 495 | description: 'Visit https://example.com or http://test.com for more info' 496 | } 497 | }; 498 | 499 | const result = validator.validateEvent(event); 500 | expect(result?.properties.description).toBe('Visit [URL] or [URL] for more info'); 501 | }); 502 | 503 | it('should handle mixed sensitive content', () => { 504 | const event: TelemetryEvent = { 505 | user_id: 'user123', 506 | event: 'test_event', 507 | properties: { 508 | message: 'Contact [email protected] at https://secure.com with key abc123def456ghi789jkl012mno345pqr' 509 | } 510 | }; 511 | 512 | const result = validator.validateEvent(event); 513 | expect(result?.properties.message).toBe('Contact [EMAIL] at [URL] with key [KEY]'); 514 | }); 515 | 516 | it('should preserve non-sensitive content', () => { 517 | const event: TelemetryEvent = { 518 | user_id: 'user123', 519 | event: 'test_event', 520 | properties: { 521 | status: 'success', 522 | count: 42, 523 | enabled: true, 524 | short_id: 'abc123' // Too short to be considered a key 525 | } 526 | }; 527 | 528 | const result = validator.validateEvent(event); 529 | expect(result?.properties.status).toBe('success'); 530 | expect(result?.properties.count).toBe(42); 531 | expect(result?.properties.enabled).toBe(true); 532 | expect(result?.properties.short_id).toBe('abc123'); 533 | }); 534 | }); 535 | 536 | describe('Error handling', () => { 537 | it('should handle Zod parsing errors gracefully', () => { 538 | const invalidEvent = { 539 | user_id: 123, // Should be string 540 | event: 'test_event', 541 | properties: {} 542 | }; 543 | 544 | const result = validator.validateEvent(invalidEvent as any); 545 | expect(result).toBeNull(); 546 | }); 547 | 548 | it('should handle unexpected errors during validation', () => { 549 | const eventWithCircularRef: any = { 550 | user_id: 'user123', 551 | event: 'test_event', 552 | properties: {} 553 | }; 554 | // Create circular reference 555 | eventWithCircularRef.properties.self = eventWithCircularRef; 556 | 557 | const result = validator.validateEvent(eventWithCircularRef); 558 | // Should handle gracefully and not throw 559 | expect(result).not.toThrow; 560 | }); 561 | }); 562 | }); ```