This is page 27 of 59. 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 │ ├── 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 │ │ │ ├── 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/mcp/tools.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from 'vitest'; 2 | import { n8nDocumentationToolsFinal } from '@/mcp/tools'; 3 | import { z } from 'zod'; 4 | 5 | describe('n8nDocumentationToolsFinal', () => { 6 | describe('Tool Structure Validation', () => { 7 | it('should have all required properties for each tool', () => { 8 | n8nDocumentationToolsFinal.forEach(tool => { 9 | // Check required properties exist 10 | expect(tool).toHaveProperty('name'); 11 | expect(tool).toHaveProperty('description'); 12 | expect(tool).toHaveProperty('inputSchema'); 13 | 14 | // Check property types 15 | expect(typeof tool.name).toBe('string'); 16 | expect(typeof tool.description).toBe('string'); 17 | expect(tool.inputSchema).toBeTypeOf('object'); 18 | 19 | // Name should be non-empty 20 | expect(tool.name.length).toBeGreaterThan(0); 21 | 22 | // Description should be meaningful 23 | expect(tool.description.length).toBeGreaterThan(10); 24 | }); 25 | }); 26 | 27 | it('should have unique tool names', () => { 28 | const names = n8nDocumentationToolsFinal.map(tool => tool.name); 29 | const uniqueNames = new Set(names); 30 | expect(names.length).toBe(uniqueNames.size); 31 | }); 32 | 33 | it('should have valid JSON Schema for all inputSchemas', () => { 34 | // Define a minimal JSON Schema validator using Zod 35 | const jsonSchemaValidator = z.object({ 36 | type: z.literal('object'), 37 | properties: z.record(z.any()).optional(), 38 | required: z.array(z.string()).optional(), 39 | }); 40 | 41 | n8nDocumentationToolsFinal.forEach(tool => { 42 | expect(() => { 43 | jsonSchemaValidator.parse(tool.inputSchema); 44 | }).not.toThrow(); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('Individual Tool Validation', () => { 50 | describe('tools_documentation', () => { 51 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'tools_documentation'); 52 | 53 | it('should exist', () => { 54 | expect(tool).toBeDefined(); 55 | }); 56 | 57 | it('should have correct schema', () => { 58 | expect(tool?.inputSchema).toMatchObject({ 59 | type: 'object', 60 | properties: { 61 | topic: { 62 | type: 'string', 63 | description: expect.any(String) 64 | }, 65 | depth: { 66 | type: 'string', 67 | enum: ['essentials', 'full'], 68 | description: expect.any(String), 69 | default: 'essentials' 70 | } 71 | } 72 | }); 73 | }); 74 | 75 | it('should have helpful description', () => { 76 | expect(tool?.description).toContain('documentation'); 77 | expect(tool?.description).toContain('MCP tools'); 78 | }); 79 | }); 80 | 81 | describe('list_nodes', () => { 82 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'list_nodes'); 83 | 84 | it('should exist', () => { 85 | expect(tool).toBeDefined(); 86 | }); 87 | 88 | it('should have correct schema properties', () => { 89 | const properties = tool?.inputSchema.properties; 90 | expect(properties).toHaveProperty('package'); 91 | expect(properties).toHaveProperty('category'); 92 | expect(properties).toHaveProperty('developmentStyle'); 93 | expect(properties).toHaveProperty('isAITool'); 94 | expect(properties).toHaveProperty('limit'); 95 | }); 96 | 97 | it('should have correct defaults', () => { 98 | expect(tool?.inputSchema.properties.limit.default).toBe(50); 99 | }); 100 | 101 | it('should have proper enum values', () => { 102 | expect(tool?.inputSchema.properties.developmentStyle.enum).toEqual(['declarative', 'programmatic']); 103 | }); 104 | }); 105 | 106 | describe('get_node_info', () => { 107 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'get_node_info'); 108 | 109 | it('should exist', () => { 110 | expect(tool).toBeDefined(); 111 | }); 112 | 113 | it('should have nodeType as required parameter', () => { 114 | expect(tool?.inputSchema.required).toContain('nodeType'); 115 | }); 116 | 117 | it('should mention performance implications in description', () => { 118 | expect(tool?.description).toMatch(/100KB\+|large|full/i); 119 | }); 120 | }); 121 | 122 | describe('search_nodes', () => { 123 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'search_nodes'); 124 | 125 | it('should exist', () => { 126 | expect(tool).toBeDefined(); 127 | }); 128 | 129 | it('should have query as required parameter', () => { 130 | expect(tool?.inputSchema.required).toContain('query'); 131 | }); 132 | 133 | it('should have mode enum with correct values', () => { 134 | expect(tool?.inputSchema.properties.mode.enum).toEqual(['OR', 'AND', 'FUZZY']); 135 | expect(tool?.inputSchema.properties.mode.default).toBe('OR'); 136 | }); 137 | 138 | it('should have limit with default value', () => { 139 | expect(tool?.inputSchema.properties.limit.default).toBe(20); 140 | }); 141 | }); 142 | 143 | describe('validate_workflow', () => { 144 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'validate_workflow'); 145 | 146 | it('should exist', () => { 147 | expect(tool).toBeDefined(); 148 | }); 149 | 150 | it('should have workflow as required parameter', () => { 151 | expect(tool?.inputSchema.required).toContain('workflow'); 152 | }); 153 | 154 | it('should have options with correct validation settings', () => { 155 | const options = tool?.inputSchema.properties.options.properties; 156 | expect(options).toHaveProperty('validateNodes'); 157 | expect(options).toHaveProperty('validateConnections'); 158 | expect(options).toHaveProperty('validateExpressions'); 159 | expect(options).toHaveProperty('profile'); 160 | }); 161 | 162 | it('should have correct profile enum values', () => { 163 | const profile = tool?.inputSchema.properties.options.properties.profile; 164 | expect(profile.enum).toEqual(['minimal', 'runtime', 'ai-friendly', 'strict']); 165 | expect(profile.default).toBe('runtime'); 166 | }); 167 | }); 168 | 169 | describe('get_templates_for_task', () => { 170 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'get_templates_for_task'); 171 | 172 | it('should exist', () => { 173 | expect(tool).toBeDefined(); 174 | }); 175 | 176 | it('should have task as required parameter', () => { 177 | expect(tool?.inputSchema.required).toContain('task'); 178 | }); 179 | 180 | it('should have correct task enum values', () => { 181 | const expectedTasks = [ 182 | 'ai_automation', 183 | 'data_sync', 184 | 'webhook_processing', 185 | 'email_automation', 186 | 'slack_integration', 187 | 'data_transformation', 188 | 'file_processing', 189 | 'scheduling', 190 | 'api_integration', 191 | 'database_operations' 192 | ]; 193 | expect(tool?.inputSchema.properties.task.enum).toEqual(expectedTasks); 194 | }); 195 | }); 196 | }); 197 | 198 | describe('Tool Description Quality', () => { 199 | it('should have concise descriptions that fit in one line', () => { 200 | n8nDocumentationToolsFinal.forEach(tool => { 201 | // Descriptions should be informative but not overly long 202 | expect(tool.description.length).toBeLessThan(300); 203 | }); 204 | }); 205 | 206 | it('should include examples or key information in descriptions', () => { 207 | const toolsWithExamples = [ 208 | 'list_nodes', 209 | 'get_node_info', 210 | 'search_nodes', 211 | 'get_node_essentials', 212 | 'get_node_documentation' 213 | ]; 214 | 215 | toolsWithExamples.forEach(toolName => { 216 | const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); 217 | // Should include either example usage, format information, or "nodes-base" 218 | expect(tool?.description).toMatch(/example|Example|format|Format|nodes-base|Common:/i); 219 | }); 220 | }); 221 | }); 222 | 223 | describe('Schema Consistency', () => { 224 | it('should use consistent parameter naming', () => { 225 | const toolsWithNodeType = n8nDocumentationToolsFinal.filter(tool => 226 | tool.inputSchema.properties?.nodeType 227 | ); 228 | 229 | toolsWithNodeType.forEach(tool => { 230 | const nodeTypeParam = tool.inputSchema.properties.nodeType; 231 | expect(nodeTypeParam.type).toBe('string'); 232 | // Should mention the prefix requirement 233 | expect(nodeTypeParam.description).toMatch(/nodes-base|prefix/i); 234 | }); 235 | }); 236 | 237 | it('should have consistent limit parameter defaults', () => { 238 | const toolsWithLimit = n8nDocumentationToolsFinal.filter(tool => 239 | tool.inputSchema.properties?.limit 240 | ); 241 | 242 | toolsWithLimit.forEach(tool => { 243 | const limitParam = tool.inputSchema.properties.limit; 244 | expect(limitParam.type).toBe('number'); 245 | expect(limitParam.default).toBeDefined(); 246 | expect(limitParam.default).toBeGreaterThan(0); 247 | }); 248 | }); 249 | }); 250 | 251 | describe('Tool Categories Coverage', () => { 252 | it('should have tools for all major categories', () => { 253 | const categories = { 254 | discovery: ['list_nodes', 'search_nodes', 'list_ai_tools'], 255 | configuration: ['get_node_info', 'get_node_essentials', 'get_node_documentation'], 256 | validation: ['validate_node_operation', 'validate_workflow', 'validate_node_minimal'], 257 | templates: ['list_tasks', 'search_templates', 'list_templates', 'get_template', 'list_node_templates'], // get_node_for_task removed in v2.15.0 258 | documentation: ['tools_documentation'] 259 | }; 260 | 261 | Object.entries(categories).forEach(([category, expectedTools]) => { 262 | expectedTools.forEach(toolName => { 263 | const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); 264 | expect(tool).toBeDefined(); 265 | }); 266 | }); 267 | }); 268 | }); 269 | 270 | describe('Parameter Validation', () => { 271 | it('should have proper type definitions for all parameters', () => { 272 | const validTypes = ['string', 'number', 'boolean', 'object', 'array']; 273 | 274 | n8nDocumentationToolsFinal.forEach(tool => { 275 | if (tool.inputSchema.properties) { 276 | Object.entries(tool.inputSchema.properties).forEach(([paramName, param]) => { 277 | expect(validTypes).toContain(param.type); 278 | expect(param.description).toBeDefined(); 279 | }); 280 | } 281 | }); 282 | }); 283 | 284 | it('should mark required parameters correctly', () => { 285 | const toolsWithRequired = n8nDocumentationToolsFinal.filter(tool => 286 | tool.inputSchema.required && tool.inputSchema.required.length > 0 287 | ); 288 | 289 | toolsWithRequired.forEach(tool => { 290 | tool.inputSchema.required!.forEach(requiredParam => { 291 | expect(tool.inputSchema.properties).toHaveProperty(requiredParam); 292 | }); 293 | }); 294 | }); 295 | }); 296 | 297 | describe('Edge Cases', () => { 298 | it('should handle tools with no parameters', () => { 299 | const toolsWithNoParams = ['list_ai_tools', 'get_database_statistics']; 300 | 301 | toolsWithNoParams.forEach(toolName => { 302 | const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); 303 | expect(tool).toBeDefined(); 304 | expect(Object.keys(tool?.inputSchema.properties || {}).length).toBe(0); 305 | }); 306 | }); 307 | 308 | it('should have array parameters defined correctly', () => { 309 | const toolsWithArrays = ['list_node_templates']; 310 | 311 | toolsWithArrays.forEach(toolName => { 312 | const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); 313 | const arrayParam = tool?.inputSchema.properties.nodeTypes; 314 | expect(arrayParam?.type).toBe('array'); 315 | expect(arrayParam?.items).toBeDefined(); 316 | expect(arrayParam?.items.type).toBe('string'); 317 | }); 318 | }); 319 | }); 320 | 321 | describe('New Template Tools', () => { 322 | describe('list_templates', () => { 323 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'list_templates'); 324 | 325 | it('should exist and be properly defined', () => { 326 | expect(tool).toBeDefined(); 327 | expect(tool?.description).toContain('minimal data'); 328 | }); 329 | 330 | it('should have correct parameters', () => { 331 | expect(tool?.inputSchema.properties).toHaveProperty('limit'); 332 | expect(tool?.inputSchema.properties).toHaveProperty('offset'); 333 | expect(tool?.inputSchema.properties).toHaveProperty('sortBy'); 334 | 335 | const limitParam = tool?.inputSchema.properties.limit; 336 | expect(limitParam.type).toBe('number'); 337 | expect(limitParam.minimum).toBe(1); 338 | expect(limitParam.maximum).toBe(100); 339 | 340 | const offsetParam = tool?.inputSchema.properties.offset; 341 | expect(offsetParam.type).toBe('number'); 342 | expect(offsetParam.minimum).toBe(0); 343 | 344 | const sortByParam = tool?.inputSchema.properties.sortBy; 345 | expect(sortByParam.enum).toEqual(['views', 'created_at', 'name']); 346 | }); 347 | 348 | it('should have no required parameters', () => { 349 | expect(tool?.inputSchema.required).toBeUndefined(); 350 | }); 351 | }); 352 | 353 | describe('get_template (enhanced)', () => { 354 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'get_template'); 355 | 356 | it('should exist and support mode parameter', () => { 357 | expect(tool).toBeDefined(); 358 | expect(tool?.description).toContain('mode'); 359 | }); 360 | 361 | it('should have mode parameter with correct values', () => { 362 | expect(tool?.inputSchema.properties).toHaveProperty('mode'); 363 | 364 | const modeParam = tool?.inputSchema.properties.mode; 365 | expect(modeParam.enum).toEqual(['nodes_only', 'structure', 'full']); 366 | expect(modeParam.default).toBe('full'); 367 | }); 368 | 369 | it('should require templateId parameter', () => { 370 | expect(tool?.inputSchema.required).toContain('templateId'); 371 | }); 372 | }); 373 | 374 | describe('search_templates_by_metadata', () => { 375 | const tool = n8nDocumentationToolsFinal.find(t => t.name === 'search_templates_by_metadata'); 376 | 377 | it('should exist in the tools array', () => { 378 | expect(tool).toBeDefined(); 379 | expect(tool?.name).toBe('search_templates_by_metadata'); 380 | }); 381 | 382 | it('should have proper description', () => { 383 | expect(tool?.description).toContain('Search templates by AI-generated metadata'); 384 | expect(tool?.description).toContain('category'); 385 | expect(tool?.description).toContain('complexity'); 386 | }); 387 | 388 | it('should have correct input schema structure', () => { 389 | expect(tool?.inputSchema.type).toBe('object'); 390 | expect(tool?.inputSchema.properties).toBeDefined(); 391 | expect(tool?.inputSchema.required).toBeUndefined(); // All parameters are optional 392 | }); 393 | 394 | it('should have category parameter with proper schema', () => { 395 | const categoryProp = tool?.inputSchema.properties?.category; 396 | expect(categoryProp).toBeDefined(); 397 | expect(categoryProp.type).toBe('string'); 398 | expect(categoryProp.description).toContain('category'); 399 | }); 400 | 401 | it('should have complexity parameter with enum values', () => { 402 | const complexityProp = tool?.inputSchema.properties?.complexity; 403 | expect(complexityProp).toBeDefined(); 404 | expect(complexityProp.enum).toEqual(['simple', 'medium', 'complex']); 405 | expect(complexityProp.description).toContain('complexity'); 406 | }); 407 | 408 | it('should have time-based parameters with numeric constraints', () => { 409 | const maxTimeProp = tool?.inputSchema.properties?.maxSetupMinutes; 410 | const minTimeProp = tool?.inputSchema.properties?.minSetupMinutes; 411 | 412 | expect(maxTimeProp).toBeDefined(); 413 | expect(maxTimeProp.type).toBe('number'); 414 | expect(maxTimeProp.maximum).toBe(480); 415 | expect(maxTimeProp.minimum).toBe(5); 416 | 417 | expect(minTimeProp).toBeDefined(); 418 | expect(minTimeProp.type).toBe('number'); 419 | expect(minTimeProp.maximum).toBe(480); 420 | expect(minTimeProp.minimum).toBe(5); 421 | }); 422 | 423 | it('should have service and audience parameters', () => { 424 | const serviceProp = tool?.inputSchema.properties?.requiredService; 425 | const audienceProp = tool?.inputSchema.properties?.targetAudience; 426 | 427 | expect(serviceProp).toBeDefined(); 428 | expect(serviceProp.type).toBe('string'); 429 | expect(serviceProp.description).toContain('service'); 430 | 431 | expect(audienceProp).toBeDefined(); 432 | expect(audienceProp.type).toBe('string'); 433 | expect(audienceProp.description).toContain('audience'); 434 | }); 435 | 436 | it('should have pagination parameters', () => { 437 | const limitProp = tool?.inputSchema.properties?.limit; 438 | const offsetProp = tool?.inputSchema.properties?.offset; 439 | 440 | expect(limitProp).toBeDefined(); 441 | expect(limitProp.type).toBe('number'); 442 | expect(limitProp.default).toBe(20); 443 | expect(limitProp.maximum).toBe(100); 444 | expect(limitProp.minimum).toBe(1); 445 | 446 | expect(offsetProp).toBeDefined(); 447 | expect(offsetProp.type).toBe('number'); 448 | expect(offsetProp.default).toBe(0); 449 | expect(offsetProp.minimum).toBe(0); 450 | }); 451 | 452 | it('should include all expected properties', () => { 453 | const properties = Object.keys(tool?.inputSchema.properties || {}); 454 | const expectedProperties = [ 455 | 'category', 456 | 'complexity', 457 | 'maxSetupMinutes', 458 | 'minSetupMinutes', 459 | 'requiredService', 460 | 'targetAudience', 461 | 'limit', 462 | 'offset' 463 | ]; 464 | 465 | expectedProperties.forEach(prop => { 466 | expect(properties).toContain(prop); 467 | }); 468 | }); 469 | 470 | it('should have appropriate additionalProperties setting', () => { 471 | expect(tool?.inputSchema.additionalProperties).toBe(false); 472 | }); 473 | }); 474 | 475 | describe('Enhanced pagination support', () => { 476 | const paginatedTools = ['list_node_templates', 'search_templates', 'get_templates_for_task', 'search_templates_by_metadata']; 477 | 478 | paginatedTools.forEach(toolName => { 479 | describe(toolName, () => { 480 | const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); 481 | 482 | it('should support limit parameter', () => { 483 | expect(tool?.inputSchema.properties).toHaveProperty('limit'); 484 | const limitParam = tool?.inputSchema.properties.limit; 485 | expect(limitParam.type).toBe('number'); 486 | expect(limitParam.minimum).toBeGreaterThanOrEqual(1); 487 | expect(limitParam.maximum).toBeGreaterThanOrEqual(50); 488 | }); 489 | 490 | it('should support offset parameter', () => { 491 | expect(tool?.inputSchema.properties).toHaveProperty('offset'); 492 | const offsetParam = tool?.inputSchema.properties.offset; 493 | expect(offsetParam.type).toBe('number'); 494 | expect(offsetParam.minimum).toBe(0); 495 | }); 496 | }); 497 | }); 498 | }); 499 | }); 500 | }); ``` -------------------------------------------------------------------------------- /tests/unit/mcp/handlers-workflow-diff.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { handleUpdatePartialWorkflow } from '@/mcp/handlers-workflow-diff'; 3 | import { WorkflowDiffEngine } from '@/services/workflow-diff-engine'; 4 | import { N8nApiClient } from '@/services/n8n-api-client'; 5 | import { 6 | N8nApiError, 7 | N8nAuthenticationError, 8 | N8nNotFoundError, 9 | N8nValidationError, 10 | N8nRateLimitError, 11 | N8nServerError, 12 | } from '@/utils/n8n-errors'; 13 | import { z } from 'zod'; 14 | 15 | // Mock dependencies 16 | vi.mock('@/services/workflow-diff-engine'); 17 | vi.mock('@/services/n8n-api-client'); 18 | vi.mock('@/config/n8n-api'); 19 | vi.mock('@/utils/logger'); 20 | vi.mock('@/mcp/handlers-n8n-manager', () => ({ 21 | getN8nApiClient: vi.fn(), 22 | })); 23 | 24 | // Import mocked modules 25 | import { getN8nApiClient } from '@/mcp/handlers-n8n-manager'; 26 | import { logger } from '@/utils/logger'; 27 | 28 | describe('handlers-workflow-diff', () => { 29 | let mockApiClient: any; 30 | let mockDiffEngine: any; 31 | 32 | // Helper function to create test workflow 33 | const createTestWorkflow = (overrides = {}) => ({ 34 | id: 'test-workflow-id', 35 | name: 'Test Workflow', 36 | active: true, 37 | nodes: [ 38 | { 39 | id: 'node1', 40 | name: 'Start', 41 | type: 'n8n-nodes-base.start', 42 | typeVersion: 1, 43 | position: [100, 100], 44 | parameters: {}, 45 | }, 46 | { 47 | id: 'node2', 48 | name: 'HTTP Request', 49 | type: 'n8n-nodes-base.httpRequest', 50 | typeVersion: 3, 51 | position: [300, 100], 52 | parameters: { url: 'https://api.test.com' }, 53 | }, 54 | ], 55 | connections: { 56 | 'Start': { 57 | main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]], 58 | }, 59 | }, 60 | createdAt: '2024-01-01T00:00:00Z', 61 | updatedAt: '2024-01-01T00:00:00Z', 62 | tags: [], 63 | settings: {}, 64 | ...overrides, 65 | }); 66 | 67 | beforeEach(() => { 68 | vi.clearAllMocks(); 69 | 70 | // Setup mock API client 71 | mockApiClient = { 72 | getWorkflow: vi.fn(), 73 | updateWorkflow: vi.fn(), 74 | }; 75 | 76 | // Setup mock diff engine 77 | mockDiffEngine = { 78 | applyDiff: vi.fn(), 79 | }; 80 | 81 | // Mock the API client getter 82 | vi.mocked(getN8nApiClient).mockReturnValue(mockApiClient); 83 | 84 | // Mock WorkflowDiffEngine constructor 85 | vi.mocked(WorkflowDiffEngine).mockImplementation(() => mockDiffEngine); 86 | 87 | // Set up default environment 88 | process.env.DEBUG_MCP = 'false'; 89 | }); 90 | 91 | describe('handleUpdatePartialWorkflow', () => { 92 | it('should apply diff operations successfully', async () => { 93 | const testWorkflow = createTestWorkflow(); 94 | const updatedWorkflow = { 95 | ...testWorkflow, 96 | nodes: [ 97 | ...testWorkflow.nodes, 98 | { 99 | id: 'node3', 100 | name: 'New Node', 101 | type: 'n8n-nodes-base.set', 102 | typeVersion: 1, 103 | position: [500, 100], 104 | parameters: {}, 105 | }, 106 | ], 107 | connections: { 108 | ...testWorkflow.connections, 109 | 'HTTP Request': { 110 | main: [[{ node: 'New Node', type: 'main', index: 0 }]], 111 | }, 112 | }, 113 | }; 114 | 115 | const diffRequest = { 116 | id: 'test-workflow-id', 117 | operations: [ 118 | { 119 | type: 'addNode', 120 | node: { 121 | id: 'node3', 122 | name: 'New Node', 123 | type: 'n8n-nodes-base.set', 124 | typeVersion: 1, 125 | position: [500, 100], 126 | parameters: {}, 127 | }, 128 | }, 129 | ], 130 | }; 131 | 132 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 133 | mockDiffEngine.applyDiff.mockResolvedValue({ 134 | success: true, 135 | workflow: updatedWorkflow, 136 | operationsApplied: 1, 137 | message: 'Successfully applied 1 operation', 138 | errors: [], 139 | applied: [0], 140 | failed: [], 141 | }); 142 | mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); 143 | 144 | const result = await handleUpdatePartialWorkflow(diffRequest); 145 | 146 | expect(result).toEqual({ 147 | success: true, 148 | data: updatedWorkflow, 149 | message: 'Workflow "Test Workflow" updated successfully. Applied 1 operations.', 150 | details: { 151 | operationsApplied: 1, 152 | workflowId: 'test-workflow-id', 153 | workflowName: 'Test Workflow', 154 | applied: [0], 155 | failed: [], 156 | errors: [], 157 | }, 158 | }); 159 | 160 | expect(mockApiClient.getWorkflow).toHaveBeenCalledWith('test-workflow-id'); 161 | expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); 162 | expect(mockApiClient.updateWorkflow).toHaveBeenCalledWith('test-workflow-id', updatedWorkflow); 163 | }); 164 | 165 | it('should handle validation-only mode', async () => { 166 | const testWorkflow = createTestWorkflow(); 167 | const diffRequest = { 168 | id: 'test-workflow-id', 169 | operations: [ 170 | { 171 | type: 'updateNode', 172 | nodeId: 'node2', 173 | updates: { name: 'Updated HTTP Request' }, 174 | }, 175 | ], 176 | validateOnly: true, 177 | }; 178 | 179 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 180 | mockDiffEngine.applyDiff.mockResolvedValue({ 181 | success: true, 182 | workflow: testWorkflow, 183 | operationsApplied: 1, 184 | message: 'Validation successful', 185 | errors: [], 186 | }); 187 | 188 | const result = await handleUpdatePartialWorkflow(diffRequest); 189 | 190 | expect(result).toEqual({ 191 | success: true, 192 | message: 'Validation successful', 193 | data: { 194 | valid: true, 195 | operationsToApply: 1, 196 | }, 197 | }); 198 | 199 | expect(mockApiClient.updateWorkflow).not.toHaveBeenCalled(); 200 | }); 201 | 202 | it('should handle multiple operations', async () => { 203 | const testWorkflow = createTestWorkflow(); 204 | const diffRequest = { 205 | id: 'test-workflow-id', 206 | operations: [ 207 | { 208 | type: 'updateNode', 209 | nodeId: 'node1', 210 | updates: { name: 'Updated Start' }, 211 | }, 212 | { 213 | type: 'addNode', 214 | node: { 215 | id: 'node3', 216 | name: 'Set Node', 217 | type: 'n8n-nodes-base.set', 218 | typeVersion: 1, 219 | position: [500, 100], 220 | parameters: {}, 221 | }, 222 | }, 223 | { 224 | type: 'addConnection', 225 | source: 'node2', 226 | target: 'node3', 227 | sourceOutput: 'main', 228 | targetInput: 'main', 229 | }, 230 | ], 231 | }; 232 | 233 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 234 | mockDiffEngine.applyDiff.mockResolvedValue({ 235 | success: true, 236 | workflow: { 237 | ...testWorkflow, 238 | nodes: [ 239 | { ...testWorkflow.nodes[0], name: 'Updated Start' }, 240 | testWorkflow.nodes[1], 241 | { 242 | id: 'node3', 243 | name: 'Set Node', 244 | type: 'n8n-nodes-base.set', 245 | typeVersion: 1, 246 | position: [500, 100], 247 | parameters: {}, 248 | } 249 | ], 250 | connections: { 251 | 'Updated Start': testWorkflow.connections['Start'], 252 | 'HTTP Request': { 253 | main: [[{ node: 'Set Node', type: 'main', index: 0 }]], 254 | }, 255 | }, 256 | }, 257 | operationsApplied: 3, 258 | message: 'Successfully applied 3 operations', 259 | errors: [], 260 | applied: [0, 1, 2], 261 | failed: [], 262 | }); 263 | mockApiClient.updateWorkflow.mockResolvedValue({ ...testWorkflow }); 264 | 265 | const result = await handleUpdatePartialWorkflow(diffRequest); 266 | 267 | expect(result.success).toBe(true); 268 | expect(result.message).toContain('Applied 3 operations'); 269 | }); 270 | 271 | it('should handle diff application failures', async () => { 272 | const testWorkflow = createTestWorkflow(); 273 | const diffRequest = { 274 | id: 'test-workflow-id', 275 | operations: [ 276 | { 277 | type: 'updateNode', 278 | nodeId: 'non-existent-node', 279 | updates: { name: 'Updated' }, 280 | }, 281 | ], 282 | }; 283 | 284 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 285 | mockDiffEngine.applyDiff.mockResolvedValue({ 286 | success: false, 287 | workflow: null, 288 | operationsApplied: 0, 289 | message: 'Failed to apply operations', 290 | errors: ['Node "non-existent-node" not found'], 291 | applied: [], 292 | failed: [0], 293 | }); 294 | 295 | const result = await handleUpdatePartialWorkflow(diffRequest); 296 | 297 | expect(result).toEqual({ 298 | success: false, 299 | error: 'Failed to apply diff operations', 300 | details: { 301 | errors: ['Node "non-existent-node" not found'], 302 | operationsApplied: 0, 303 | applied: [], 304 | failed: [0], 305 | }, 306 | }); 307 | 308 | expect(mockApiClient.updateWorkflow).not.toHaveBeenCalled(); 309 | }); 310 | 311 | it('should handle API not configured error', async () => { 312 | vi.mocked(getN8nApiClient).mockReturnValue(null); 313 | 314 | const result = await handleUpdatePartialWorkflow({ 315 | id: 'test-id', 316 | operations: [], 317 | }); 318 | 319 | expect(result).toEqual({ 320 | success: false, 321 | error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.', 322 | }); 323 | }); 324 | 325 | it('should handle workflow not found error', async () => { 326 | const notFoundError = new N8nNotFoundError('Workflow', 'non-existent'); 327 | mockApiClient.getWorkflow.mockRejectedValue(notFoundError); 328 | 329 | const result = await handleUpdatePartialWorkflow({ 330 | id: 'non-existent', 331 | operations: [], 332 | }); 333 | 334 | expect(result).toEqual({ 335 | success: false, 336 | error: 'Workflow with ID non-existent not found', 337 | code: 'NOT_FOUND', 338 | }); 339 | }); 340 | 341 | it('should handle API errors during update', async () => { 342 | const testWorkflow = createTestWorkflow(); 343 | const validationError = new N8nValidationError('Invalid workflow structure', { 344 | field: 'connections', 345 | message: 'Invalid connection configuration', 346 | }); 347 | 348 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 349 | mockDiffEngine.applyDiff.mockResolvedValue({ 350 | success: true, 351 | workflow: testWorkflow, 352 | operationsApplied: 1, 353 | message: 'Success', 354 | errors: [], 355 | }); 356 | mockApiClient.updateWorkflow.mockRejectedValue(validationError); 357 | 358 | const result = await handleUpdatePartialWorkflow({ 359 | id: 'test-id', 360 | operations: [{ type: 'updateNode', nodeId: 'node1', updates: {} }], 361 | }); 362 | 363 | expect(result).toEqual({ 364 | success: false, 365 | error: 'Invalid request: Invalid workflow structure', 366 | code: 'VALIDATION_ERROR', 367 | details: { 368 | field: 'connections', 369 | message: 'Invalid connection configuration', 370 | }, 371 | }); 372 | }); 373 | 374 | it('should handle input validation errors', async () => { 375 | const invalidInput = { 376 | id: 'test-id', 377 | operations: [ 378 | { 379 | // Missing required 'type' field 380 | nodeId: 'node1', 381 | updates: {}, 382 | }, 383 | ], 384 | }; 385 | 386 | const result = await handleUpdatePartialWorkflow(invalidInput); 387 | 388 | expect(result.success).toBe(false); 389 | expect(result.error).toBe('Invalid input'); 390 | expect(result.details).toHaveProperty('errors'); 391 | expect(result.details?.errors).toBeInstanceOf(Array); 392 | }); 393 | 394 | it('should handle complex operation types', async () => { 395 | const testWorkflow = createTestWorkflow(); 396 | const diffRequest = { 397 | id: 'test-workflow-id', 398 | operations: [ 399 | { 400 | type: 'moveNode', 401 | nodeId: 'node2', 402 | position: [400, 200], 403 | }, 404 | { 405 | type: 'removeConnection', 406 | source: 'node1', 407 | target: 'node2', 408 | sourceOutput: 'main', 409 | targetInput: 'main', 410 | }, 411 | { 412 | type: 'updateSettings', 413 | settings: { 414 | executionOrder: 'v1', 415 | timezone: 'America/New_York', 416 | }, 417 | }, 418 | { 419 | type: 'addTag', 420 | tag: 'automated', 421 | }, 422 | ], 423 | }; 424 | 425 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 426 | mockDiffEngine.applyDiff.mockResolvedValue({ 427 | success: true, 428 | workflow: { ...testWorkflow, settings: { executionOrder: 'v1' } }, 429 | operationsApplied: 4, 430 | message: 'Successfully applied 4 operations', 431 | errors: [], 432 | }); 433 | mockApiClient.updateWorkflow.mockResolvedValue({ ...testWorkflow }); 434 | 435 | const result = await handleUpdatePartialWorkflow(diffRequest); 436 | 437 | expect(result.success).toBe(true); 438 | expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); 439 | }); 440 | 441 | it('should handle debug logging when enabled', async () => { 442 | process.env.DEBUG_MCP = 'true'; 443 | const testWorkflow = createTestWorkflow(); 444 | 445 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 446 | mockDiffEngine.applyDiff.mockResolvedValue({ 447 | success: true, 448 | workflow: testWorkflow, 449 | operationsApplied: 1, 450 | message: 'Success', 451 | errors: [], 452 | }); 453 | mockApiClient.updateWorkflow.mockResolvedValue(testWorkflow); 454 | 455 | await handleUpdatePartialWorkflow({ 456 | id: 'test-id', 457 | operations: [{ type: 'updateNode', nodeId: 'node1', updates: {} }], 458 | }); 459 | 460 | expect(logger.debug).toHaveBeenCalledWith( 461 | 'Workflow diff request received', 462 | expect.objectContaining({ 463 | argsType: 'object', 464 | operationCount: 1, 465 | }) 466 | ); 467 | }); 468 | 469 | it('should handle generic errors', async () => { 470 | const genericError = new Error('Something went wrong'); 471 | mockApiClient.getWorkflow.mockRejectedValue(genericError); 472 | 473 | const result = await handleUpdatePartialWorkflow({ 474 | id: 'test-id', 475 | operations: [], 476 | }); 477 | 478 | expect(result).toEqual({ 479 | success: false, 480 | error: 'Something went wrong', 481 | }); 482 | expect(logger.error).toHaveBeenCalledWith('Failed to update partial workflow', genericError); 483 | }); 484 | 485 | it('should handle authentication errors', async () => { 486 | const authError = new N8nAuthenticationError('Invalid API key'); 487 | mockApiClient.getWorkflow.mockRejectedValue(authError); 488 | 489 | const result = await handleUpdatePartialWorkflow({ 490 | id: 'test-id', 491 | operations: [], 492 | }); 493 | 494 | expect(result).toEqual({ 495 | success: false, 496 | error: 'Failed to authenticate with n8n. Please check your API key.', 497 | code: 'AUTHENTICATION_ERROR', 498 | }); 499 | }); 500 | 501 | it('should handle rate limit errors', async () => { 502 | const rateLimitError = new N8nRateLimitError(60); 503 | mockApiClient.getWorkflow.mockRejectedValue(rateLimitError); 504 | 505 | const result = await handleUpdatePartialWorkflow({ 506 | id: 'test-id', 507 | operations: [], 508 | }); 509 | 510 | expect(result).toEqual({ 511 | success: false, 512 | error: 'Too many requests. Please wait a moment and try again.', 513 | code: 'RATE_LIMIT_ERROR', 514 | }); 515 | }); 516 | 517 | it('should handle server errors', async () => { 518 | const serverError = new N8nServerError('Internal server error'); 519 | mockApiClient.getWorkflow.mockRejectedValue(serverError); 520 | 521 | const result = await handleUpdatePartialWorkflow({ 522 | id: 'test-id', 523 | operations: [], 524 | }); 525 | 526 | expect(result).toEqual({ 527 | success: false, 528 | error: 'Internal server error', 529 | code: 'SERVER_ERROR', 530 | }); 531 | }); 532 | 533 | it('should validate operation structure', async () => { 534 | const testWorkflow = createTestWorkflow(); 535 | const diffRequest = { 536 | id: 'test-workflow-id', 537 | operations: [ 538 | { 539 | type: 'updateNode', 540 | nodeId: 'node1', 541 | nodeName: 'Start', // Both nodeId and nodeName provided 542 | updates: { name: 'New Start' }, 543 | description: 'Update start node name', 544 | }, 545 | { 546 | type: 'addConnection', 547 | source: 'node1', 548 | target: 'node2', 549 | sourceOutput: 'main', 550 | targetInput: 'main', 551 | sourceIndex: 0, 552 | targetIndex: 0, 553 | }, 554 | ], 555 | }; 556 | 557 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 558 | mockDiffEngine.applyDiff.mockResolvedValue({ 559 | success: true, 560 | workflow: testWorkflow, 561 | operationsApplied: 2, 562 | message: 'Success', 563 | errors: [], 564 | }); 565 | mockApiClient.updateWorkflow.mockResolvedValue(testWorkflow); 566 | 567 | const result = await handleUpdatePartialWorkflow(diffRequest); 568 | 569 | expect(result.success).toBe(true); 570 | expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); 571 | }); 572 | 573 | it('should handle empty operations array', async () => { 574 | const testWorkflow = createTestWorkflow(); 575 | const diffRequest = { 576 | id: 'test-workflow-id', 577 | operations: [], 578 | }; 579 | 580 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 581 | mockDiffEngine.applyDiff.mockResolvedValue({ 582 | success: true, 583 | workflow: testWorkflow, 584 | operationsApplied: 0, 585 | message: 'No operations to apply', 586 | errors: [], 587 | }); 588 | mockApiClient.updateWorkflow.mockResolvedValue(testWorkflow); 589 | 590 | const result = await handleUpdatePartialWorkflow(diffRequest); 591 | 592 | expect(result.success).toBe(true); 593 | expect(result.message).toContain('Applied 0 operations'); 594 | }); 595 | 596 | it('should handle partial diff application', async () => { 597 | const testWorkflow = createTestWorkflow(); 598 | const diffRequest = { 599 | id: 'test-workflow-id', 600 | operations: [ 601 | { type: 'updateNode', nodeId: 'node1', updates: { name: 'Updated' } }, 602 | { type: 'updateNode', nodeId: 'invalid-node', updates: { name: 'Fail' } }, 603 | { type: 'addTag', tag: 'test' }, 604 | ], 605 | }; 606 | 607 | mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); 608 | mockDiffEngine.applyDiff.mockResolvedValue({ 609 | success: false, 610 | workflow: null, 611 | operationsApplied: 1, 612 | message: 'Partially applied operations', 613 | errors: ['Operation 2 failed: Node "invalid-node" not found'], 614 | }); 615 | 616 | const result = await handleUpdatePartialWorkflow(diffRequest); 617 | 618 | expect(result).toEqual({ 619 | success: false, 620 | error: 'Failed to apply diff operations', 621 | details: { 622 | errors: ['Operation 2 failed: Node "invalid-node" not found'], 623 | operationsApplied: 1, 624 | }, 625 | }); 626 | }); 627 | }); 628 | }); ``` -------------------------------------------------------------------------------- /src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ToolDocumentation } from '../types'; 2 | 3 | export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = { 4 | name: 'n8n_update_partial_workflow', 5 | category: 'workflow_management', 6 | essentials: { 7 | description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).', 8 | keyParameters: ['id', 'operations', 'continueOnError'], 9 | example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})', 10 | performance: 'Fast (50-200ms)', 11 | tips: [ 12 | 'Use rewireConnection to change connection targets', 13 | 'Use branch="true"/"false" for IF nodes', 14 | 'Use case=N for Switch nodes', 15 | 'Use cleanStaleConnections to auto-remove broken connections', 16 | 'Set ignoreErrors:true on removeConnection for cleanup', 17 | 'Use continueOnError mode for best-effort bulk operations', 18 | 'Validate with validateOnly first', 19 | 'For AI connections, specify sourceOutput type (ai_languageModel, ai_tool, etc.)', 20 | 'Batch AI component connections for atomic updates', 21 | 'Auto-sanitization: ALL nodes auto-fixed during updates (operator structures, missing metadata)' 22 | ] 23 | }, 24 | full: { 25 | description: `Updates workflows using surgical diff operations instead of full replacement. Supports 15 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied. 26 | 27 | ## Available Operations: 28 | 29 | ### Node Operations (6 types): 30 | - **addNode**: Add a new node with name, type, and position (required) 31 | - **removeNode**: Remove a node by ID or name 32 | - **updateNode**: Update node properties using dot notation (e.g., 'parameters.url') 33 | - **moveNode**: Change node position [x, y] 34 | - **enableNode**: Enable a disabled node 35 | - **disableNode**: Disable an active node 36 | 37 | ### Connection Operations (5 types): 38 | - **addConnection**: Connect nodes (source→target). Supports smart parameters: branch="true"/"false" for IF nodes, case=N for Switch nodes. 39 | - **removeConnection**: Remove connection between nodes (supports ignoreErrors flag) 40 | - **rewireConnection**: Change connection target from one node to another. Supports smart parameters. 41 | - **cleanStaleConnections**: Auto-remove all connections referencing non-existent nodes 42 | - **replaceConnections**: Replace entire connections object 43 | 44 | ### Metadata Operations (4 types): 45 | - **updateSettings**: Modify workflow settings 46 | - **updateName**: Rename the workflow 47 | - **addTag**: Add a workflow tag 48 | - **removeTag**: Remove a workflow tag 49 | 50 | ## Smart Parameters for Multi-Output Nodes 51 | 52 | For **IF nodes**, use semantic 'branch' parameter instead of technical sourceIndex: 53 | - **branch="true"**: Routes to true branch (sourceIndex=0) 54 | - **branch="false"**: Routes to false branch (sourceIndex=1) 55 | 56 | For **Switch nodes**, use semantic 'case' parameter: 57 | - **case=0**: First output 58 | - **case=1**: Second output 59 | - **case=N**: Nth output 60 | 61 | Works with addConnection and rewireConnection operations. Explicit sourceIndex overrides smart parameters. 62 | 63 | ## AI Connection Support 64 | 65 | Full support for all 8 AI connection types used in n8n AI workflows: 66 | 67 | **Connection Types**: 68 | - **ai_languageModel**: Connect language models (OpenAI, Anthropic, Google Gemini) to AI Agents 69 | - **ai_tool**: Connect tools (HTTP Request Tool, Code Tool, etc.) to AI Agents 70 | - **ai_memory**: Connect memory systems (Window Buffer, Conversation Summary) to AI Agents 71 | - **ai_outputParser**: Connect output parsers (Structured, JSON) to AI Agents 72 | - **ai_embedding**: Connect embedding models to Vector Stores 73 | - **ai_vectorStore**: Connect vector stores to Vector Store Tools 74 | - **ai_document**: Connect document loaders to Vector Stores 75 | - **ai_textSplitter**: Connect text splitters to document processing chains 76 | 77 | **AI Connection Examples**: 78 | - Single connection: \`{type: "addConnection", source: "OpenAI", target: "AI Agent", sourceOutput: "ai_languageModel"}\` 79 | - Fallback model: Use targetIndex (0=primary, 1=fallback) for dual language model setup 80 | - Multiple tools: Batch multiple \`sourceOutput: "ai_tool"\` connections to one AI Agent 81 | - Vector retrieval: Chain ai_embedding → ai_vectorStore → ai_tool → AI Agent 82 | 83 | **Best Practices**: 84 | - Always specify \`sourceOutput\` for AI connections (defaults to "main" if omitted) 85 | - Connect language model BEFORE creating/enabling AI Agent (validation requirement) 86 | - Use atomic mode (default) when setting up AI workflows to ensure complete configuration 87 | - Validate AI workflows after changes with \`n8n_validate_workflow\` tool 88 | 89 | ## Cleanup & Recovery Features 90 | 91 | ### Automatic Cleanup 92 | The **cleanStaleConnections** operation automatically removes broken connection references after node renames/deletions. Essential for workflow recovery. 93 | 94 | ### Best-Effort Mode 95 | Set **continueOnError: true** to apply valid operations even if some fail. Returns detailed results showing which operations succeeded/failed. Perfect for bulk cleanup operations. 96 | 97 | ### Graceful Error Handling 98 | Add **ignoreErrors: true** to removeConnection operations to prevent failures when connections don't exist. 99 | 100 | ## Auto-Sanitization System 101 | 102 | ### What Gets Auto-Fixed 103 | When ANY workflow update is made, ALL nodes in the workflow are automatically sanitized to ensure complete metadata and correct structure: 104 | 105 | 1. **Operator Structure Fixes**: 106 | - Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed 107 | - Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added 108 | - Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\` 109 | 110 | 2. **Missing Metadata Added**: 111 | - IF v2.2+ nodes get complete \`conditions.options\` structure if missing 112 | - Switch v3.2+ nodes get complete \`conditions.options\` for all rules 113 | - Required fields: \`{version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}\` 114 | 115 | ### Sanitization Scope 116 | - Runs on **ALL nodes** in the workflow, not just modified ones 117 | - Triggered by ANY update operation (addNode, updateNode, addConnection, etc.) 118 | - Prevents workflow corruption that would make UI unrenderable 119 | 120 | ### Limitations 121 | Auto-sanitization CANNOT fix: 122 | - Broken connections (connections referencing non-existent nodes) - use \`cleanStaleConnections\` 123 | - Branch count mismatches (e.g., Switch with 3 rules but only 2 outputs) - requires manual connection fixes 124 | - Workflows in paradoxical corrupt states (API returns corrupt data, API rejects updates) - must recreate workflow 125 | 126 | ### Recovery Guidance 127 | If validation still fails after auto-sanitization: 128 | 1. Check error details for specific issues 129 | 2. Use \`validate_workflow\` to see all validation errors 130 | 3. For connection issues, use \`cleanStaleConnections\` operation 131 | 4. For branch mismatches, add missing output connections 132 | 5. For paradoxical corrupted workflows, create new workflow and migrate nodes`, 133 | parameters: { 134 | id: { type: 'string', required: true, description: 'Workflow ID to update' }, 135 | operations: { 136 | type: 'array', 137 | required: true, 138 | description: 'Array of diff operations. Each must have "type" field and operation-specific properties. Nodes can be referenced by ID or name.' 139 | }, 140 | validateOnly: { type: 'boolean', description: 'If true, only validate operations without applying them' }, 141 | continueOnError: { type: 'boolean', description: 'If true, apply valid operations even if some fail (best-effort mode). Returns applied and failed operation indices. Default: false (atomic)' } 142 | }, 143 | returns: 'Updated workflow object or validation results if validateOnly=true', 144 | examples: [ 145 | '// Add a basic node (minimal configuration)\nn8n_update_partial_workflow({id: "abc", operations: [{type: "addNode", node: {name: "Process Data", type: "n8n-nodes-base.set", position: [400, 300], parameters: {}}}]})', 146 | '// Add node with full configuration\nn8n_update_partial_workflow({id: "def", operations: [{type: "addNode", node: {name: "Send Slack Alert", type: "n8n-nodes-base.slack", position: [600, 300], typeVersion: 2, parameters: {resource: "message", operation: "post", channel: "#alerts", text: "Success!"}}}]})', 147 | '// Add node AND connect it (common pattern)\nn8n_update_partial_workflow({id: "ghi", operations: [\n {type: "addNode", node: {name: "HTTP Request", type: "n8n-nodes-base.httpRequest", position: [400, 300], parameters: {url: "https://api.example.com", method: "GET"}}},\n {type: "addConnection", source: "Webhook", target: "HTTP Request"}\n]})', 148 | '// Rewire connection from one target to another\nn8n_update_partial_workflow({id: "xyz", operations: [{type: "rewireConnection", source: "Webhook", from: "Old Handler", to: "New Handler"}]})', 149 | '// Smart parameter: IF node true branch\nn8n_update_partial_workflow({id: "abc", operations: [{type: "addConnection", source: "IF", target: "Success Handler", branch: "true"}]})', 150 | '// Smart parameter: IF node false branch\nn8n_update_partial_workflow({id: "def", operations: [{type: "addConnection", source: "IF", target: "Error Handler", branch: "false"}]})', 151 | '// Smart parameter: Switch node case routing\nn8n_update_partial_workflow({id: "ghi", operations: [\n {type: "addConnection", source: "Switch", target: "Handler A", case: 0},\n {type: "addConnection", source: "Switch", target: "Handler B", case: 1},\n {type: "addConnection", source: "Switch", target: "Handler C", case: 2}\n]})', 152 | '// Rewire with smart parameter\nn8n_update_partial_workflow({id: "jkl", operations: [{type: "rewireConnection", source: "IF", from: "Old True Handler", to: "New True Handler", branch: "true"}]})', 153 | '// Add multiple nodes in batch\nn8n_update_partial_workflow({id: "mno", operations: [\n {type: "addNode", node: {name: "Filter", type: "n8n-nodes-base.filter", position: [400, 300], parameters: {}}},\n {type: "addNode", node: {name: "Transform", type: "n8n-nodes-base.set", position: [600, 300], parameters: {}}},\n {type: "addConnection", source: "Filter", target: "Transform"}\n]})', 154 | '// Clean up stale connections after node renames/deletions\nn8n_update_partial_workflow({id: "pqr", operations: [{type: "cleanStaleConnections"}]})', 155 | '// Remove connection gracefully (no error if it doesn\'t exist)\nn8n_update_partial_workflow({id: "stu", operations: [{type: "removeConnection", source: "Old Node", target: "Target", ignoreErrors: true}]})', 156 | '// Best-effort mode: apply what works, report what fails\nn8n_update_partial_workflow({id: "vwx", operations: [\n {type: "updateName", name: "Fixed Workflow"},\n {type: "removeConnection", source: "Broken", target: "Node"},\n {type: "cleanStaleConnections"}\n], continueOnError: true})', 157 | '// Update node parameter\nn8n_update_partial_workflow({id: "yza", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.url": "https://api.example.com"}}]})', 158 | '// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})', 159 | '\n// ============ AI CONNECTION EXAMPLES ============', 160 | '// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})', 161 | '// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})', 162 | '// Connect memory to AI Agent\nn8n_update_partial_workflow({id: "ai3", operations: [{type: "addConnection", source: "Window Buffer Memory", target: "AI Agent", sourceOutput: "ai_memory"}]})', 163 | '// Connect output parser to AI Agent\nn8n_update_partial_workflow({id: "ai4", operations: [{type: "addConnection", source: "Structured Output Parser", target: "AI Agent", sourceOutput: "ai_outputParser"}]})', 164 | '// Complete AI Agent setup: Add language model, tools, and memory\nn8n_update_partial_workflow({id: "ai5", operations: [\n {type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"},\n {type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "Code Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "Window Buffer Memory", target: "AI Agent", sourceOutput: "ai_memory"}\n]})', 165 | '// Add fallback model to AI Agent (requires v2.1+)\nn8n_update_partial_workflow({id: "ai6", operations: [\n {type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 0},\n {type: "addConnection", source: "Anthropic Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 1}\n]})', 166 | '// Vector Store setup: Connect embeddings and documents\nn8n_update_partial_workflow({id: "ai7", operations: [\n {type: "addConnection", source: "Embeddings OpenAI", target: "Pinecone Vector Store", sourceOutput: "ai_embedding"},\n {type: "addConnection", source: "Default Data Loader", target: "Pinecone Vector Store", sourceOutput: "ai_document"}\n]})', 167 | '// Connect Vector Store Tool to AI Agent (retrieval setup)\nn8n_update_partial_workflow({id: "ai8", operations: [\n {type: "addConnection", source: "Pinecone Vector Store", target: "Vector Store Tool", sourceOutput: "ai_vectorStore"},\n {type: "addConnection", source: "Vector Store Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})', 168 | '// Rewire AI Agent to use different language model\nn8n_update_partial_workflow({id: "ai9", operations: [{type: "rewireConnection", source: "AI Agent", from: "OpenAI Chat Model", to: "Anthropic Chat Model", sourceOutput: "ai_languageModel"}]})', 169 | '// Replace all AI tools for an agent\nn8n_update_partial_workflow({id: "ai10", operations: [\n {type: "removeConnection", source: "Old Tool 1", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "removeConnection", source: "Old Tool 2", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New HTTP Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New Code Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})' 170 | ], 171 | useCases: [ 172 | 'Rewire connections when replacing nodes', 173 | 'Route IF/Switch node outputs with semantic parameters', 174 | 'Clean up broken workflows after node renames/deletions', 175 | 'Bulk connection cleanup with best-effort mode', 176 | 'Update single node parameters', 177 | 'Replace all connections at once', 178 | 'Graceful cleanup operations that don\'t fail', 179 | 'Enable/disable nodes', 180 | 'Rename workflows or nodes', 181 | 'Manage tags efficiently', 182 | 'Connect AI components (language models, tools, memory, parsers)', 183 | 'Set up AI Agent workflows with multiple tools', 184 | 'Add fallback language models to AI Agents', 185 | 'Configure Vector Store retrieval systems', 186 | 'Swap language models in existing AI workflows', 187 | 'Batch-update AI tool connections' 188 | ], 189 | performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.', 190 | bestPractices: [ 191 | 'Use rewireConnection instead of remove+add for changing targets', 192 | 'Use branch="true"/"false" for IF nodes instead of sourceIndex', 193 | 'Use case=N for Switch nodes instead of sourceIndex', 194 | 'Use cleanStaleConnections after renaming/removing nodes', 195 | 'Use continueOnError for bulk cleanup operations', 196 | 'Set ignoreErrors:true on removeConnection for graceful cleanup', 197 | 'Use validateOnly to test operations before applying', 198 | 'Group related changes in one call', 199 | 'Check operation order for dependencies', 200 | 'Use atomic mode (default) for critical updates', 201 | 'For AI connections, always specify sourceOutput (ai_languageModel, ai_tool, ai_memory, etc.)', 202 | 'Connect language model BEFORE adding AI Agent to ensure validation passes', 203 | 'Use targetIndex for fallback models (primary=0, fallback=1)', 204 | 'Batch AI component connections in a single operation for atomicity', 205 | 'Validate AI workflows after connection changes to catch configuration errors' 206 | ], 207 | pitfalls: [ 208 | '**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access', 209 | 'Atomic mode (default): all operations must succeed or none are applied', 210 | 'continueOnError breaks atomic guarantees - use with caution', 211 | 'Order matters for dependent operations (e.g., must add node before connecting to it)', 212 | 'Node references accept ID or name, but name must be unique', 213 | 'Node names with special characters (apostrophes, quotes) work correctly', 214 | 'For best compatibility, prefer node IDs over names when dealing with special characters', 215 | 'Use "updates" property for updateNode operations: {type: "updateNode", updates: {...}}', 216 | 'Smart parameters (branch, case) only work with IF and Switch nodes - ignored for other node types', 217 | 'Explicit sourceIndex overrides smart parameters (branch, case) if both provided', 218 | 'cleanStaleConnections removes ALL broken connections - cannot be selective', 219 | 'replaceConnections overwrites entire connections object - all previous connections lost', 220 | '**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (isEmpty, isNotEmpty) automatically get singleValue:true added', 221 | '**Auto-sanitization runs on ALL nodes**: When ANY update is made, ALL nodes in the workflow are sanitized (not just modified ones)', 222 | '**Auto-sanitization cannot fix everything**: It fixes operator structures and missing metadata, but cannot fix broken connections or branch mismatches', 223 | '**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated' 224 | ], 225 | relatedTools: ['n8n_update_full_workflow', 'n8n_get_workflow', 'validate_workflow', 'tools_documentation'] 226 | } 227 | }; ``` -------------------------------------------------------------------------------- /tests/unit/docker/edge-cases.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { execSync } from 'child_process'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import os from 'os'; 6 | 7 | describe('Docker Config Edge Cases', () => { 8 | let tempDir: string; 9 | let configPath: string; 10 | const parseConfigPath = path.resolve(__dirname, '../../../docker/parse-config.js'); 11 | 12 | beforeEach(() => { 13 | tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edge-cases-test-')); 14 | configPath = path.join(tempDir, 'config.json'); 15 | }); 16 | 17 | afterEach(() => { 18 | if (fs.existsSync(tempDir)) { 19 | fs.rmSync(tempDir, { recursive: true }); 20 | } 21 | }); 22 | 23 | describe('Data type edge cases', () => { 24 | it('should handle JavaScript number edge cases', () => { 25 | // Note: JSON.stringify converts Infinity/-Infinity/NaN to null 26 | // So we need to test with a pre-stringified JSON that would have these values 27 | const configJson = `{ 28 | "max_safe_int": ${Number.MAX_SAFE_INTEGER}, 29 | "min_safe_int": ${Number.MIN_SAFE_INTEGER}, 30 | "positive_zero": 0, 31 | "negative_zero": -0, 32 | "very_small": 1e-308, 33 | "very_large": 1e308, 34 | "float_precision": 0.30000000000000004 35 | }`; 36 | fs.writeFileSync(configPath, configJson); 37 | 38 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 39 | 40 | expect(output).toContain(`export MAX_SAFE_INT='${Number.MAX_SAFE_INTEGER}'`); 41 | expect(output).toContain(`export MIN_SAFE_INT='${Number.MIN_SAFE_INTEGER}'`); 42 | expect(output).toContain("export POSITIVE_ZERO='0'"); 43 | expect(output).toContain("export NEGATIVE_ZERO='0'"); // -0 becomes 0 in JSON 44 | expect(output).toContain("export VERY_SMALL='1e-308'"); 45 | expect(output).toContain("export VERY_LARGE='1e+308'"); 46 | expect(output).toContain("export FLOAT_PRECISION='0.30000000000000004'"); 47 | 48 | // Test null values (what Infinity/NaN become in JSON) 49 | const configWithNull = { test_null: null, test_array: [1, 2], test_undefined: undefined }; 50 | fs.writeFileSync(configPath, JSON.stringify(configWithNull)); 51 | const output2 = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 52 | // null values and arrays are skipped 53 | expect(output2).toBe(''); 54 | }); 55 | 56 | it('should handle unusual but valid JSON structures', () => { 57 | const config = { 58 | "": "empty key", 59 | "123": "numeric key", 60 | "true": "boolean key", 61 | "null": "null key", 62 | "undefined": "undefined key", 63 | "[object Object]": "object string key", 64 | "key\nwith\nnewlines": "multiline key", 65 | "key\twith\ttabs": "tab key", 66 | "🔑": "emoji key", 67 | "ключ": "cyrillic key", 68 | "キー": "japanese key", 69 | "مفتاح": "arabic key" 70 | }; 71 | fs.writeFileSync(configPath, JSON.stringify(config)); 72 | 73 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 74 | 75 | // Empty key is skipped (becomes EMPTY_KEY and then filtered out) 76 | expect(output).not.toContain("empty key"); 77 | 78 | // Numeric key gets prefixed with underscore 79 | expect(output).toContain("export _123='numeric key'"); 80 | 81 | // Other keys are transformed 82 | expect(output).toContain("export TRUE='boolean key'"); 83 | expect(output).toContain("export NULL='null key'"); 84 | expect(output).toContain("export UNDEFINED='undefined key'"); 85 | expect(output).toContain("export OBJECT_OBJECT='object string key'"); 86 | expect(output).toContain("export KEY_WITH_NEWLINES='multiline key'"); 87 | expect(output).toContain("export KEY_WITH_TABS='tab key'"); 88 | 89 | // Non-ASCII characters are replaced with underscores 90 | // But if the result is empty after sanitization, they're skipped 91 | const lines = output.trim().split('\n'); 92 | // emoji, cyrillic, japanese, arabic keys all become empty after sanitization and are skipped 93 | expect(lines.length).toBe(7); // Only the ASCII-based keys remain 94 | }); 95 | 96 | it('should handle circular reference prevention in nested configs', () => { 97 | // Create a config that would have circular references if not handled properly 98 | const config = { 99 | level1: { 100 | level2: { 101 | level3: { 102 | circular_ref: "This would reference level1 in a real circular structure" 103 | } 104 | }, 105 | sibling: { 106 | ref_to_level2: "Reference to sibling" 107 | } 108 | } 109 | }; 110 | fs.writeFileSync(configPath, JSON.stringify(config)); 111 | 112 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 113 | 114 | expect(output).toContain("export LEVEL1_LEVEL2_LEVEL3_CIRCULAR_REF='This would reference level1 in a real circular structure'"); 115 | expect(output).toContain("export LEVEL1_SIBLING_REF_TO_LEVEL2='Reference to sibling'"); 116 | }); 117 | }); 118 | 119 | describe('File system edge cases', () => { 120 | it('should handle permission errors gracefully', () => { 121 | if (process.platform === 'win32') { 122 | // Skip on Windows as permission handling is different 123 | return; 124 | } 125 | 126 | // Create a file with no read permissions 127 | fs.writeFileSync(configPath, '{"test": "value"}'); 128 | fs.chmodSync(configPath, 0o000); 129 | 130 | try { 131 | const output = execSync(`node "${parseConfigPath}" "${configPath}" 2>&1`, { encoding: 'utf8' }); 132 | // Should exit silently even with permission error 133 | expect(output).toBe(''); 134 | } finally { 135 | // Restore permissions for cleanup 136 | fs.chmodSync(configPath, 0o644); 137 | } 138 | }); 139 | 140 | it('should handle symlinks correctly', () => { 141 | const actualConfig = path.join(tempDir, 'actual-config.json'); 142 | const symlinkPath = path.join(tempDir, 'symlink-config.json'); 143 | 144 | fs.writeFileSync(actualConfig, '{"symlink_test": "value"}'); 145 | fs.symlinkSync(actualConfig, symlinkPath); 146 | 147 | const output = execSync(`node "${parseConfigPath}" "${symlinkPath}"`, { encoding: 'utf8' }); 148 | 149 | expect(output).toContain("export SYMLINK_TEST='value'"); 150 | }); 151 | 152 | it('should handle very large config files', () => { 153 | // Create a large config with many keys 154 | const largeConfig: Record<string, any> = {}; 155 | for (let i = 0; i < 10000; i++) { 156 | largeConfig[`key_${i}`] = `value_${i}`; 157 | } 158 | fs.writeFileSync(configPath, JSON.stringify(largeConfig)); 159 | 160 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 161 | 162 | const lines = output.trim().split('\n'); 163 | expect(lines.length).toBe(10000); 164 | expect(output).toContain("export KEY_0='value_0'"); 165 | expect(output).toContain("export KEY_9999='value_9999'"); 166 | }); 167 | }); 168 | 169 | describe('JSON parsing edge cases', () => { 170 | it('should handle various invalid JSON formats', () => { 171 | const invalidJsonCases = [ 172 | '{invalid}', // Missing quotes 173 | "{'single': 'quotes'}", // Single quotes 174 | '{test: value}', // Unquoted keys 175 | '{"test": undefined}', // Undefined value 176 | '{"test": function() {}}', // Function 177 | '{,}', // Invalid structure 178 | '{"a": 1,}', // Trailing comma 179 | 'null', // Just null 180 | 'true', // Just boolean 181 | '"string"', // Just string 182 | '123', // Just number 183 | '[]', // Empty array 184 | '[1, 2, 3]', // Array 185 | ]; 186 | 187 | invalidJsonCases.forEach(invalidJson => { 188 | fs.writeFileSync(configPath, invalidJson); 189 | const output = execSync(`node "${parseConfigPath}" "${configPath}" 2>&1`, { encoding: 'utf8' }); 190 | // Should exit silently on invalid JSON 191 | expect(output).toBe(''); 192 | }); 193 | }); 194 | 195 | it('should handle Unicode edge cases in JSON', () => { 196 | const config = { 197 | // Various Unicode scenarios 198 | zero_width: "test\u200B\u200C\u200Dtest", // Zero-width characters 199 | bom: "\uFEFFtest", // Byte order mark 200 | surrogate_pair: "𝕳𝖊𝖑𝖑𝖔", // Mathematical bold text 201 | rtl_text: "مرحبا mixed עברית", // Right-to-left text 202 | combining: "é" + "é", // Combining vs precomposed 203 | control_chars: "test\u0001\u0002\u0003test", 204 | emoji_zwj: "👨👩👧👦", // Family emoji with ZWJ 205 | invalid_surrogate: "test\uD800test", // Invalid surrogate 206 | }; 207 | fs.writeFileSync(configPath, JSON.stringify(config)); 208 | 209 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 210 | 211 | // All Unicode should be preserved in values 212 | expect(output).toContain("export ZERO_WIDTH='test\u200B\u200C\u200Dtest'"); 213 | expect(output).toContain("export BOM='\uFEFFtest'"); 214 | expect(output).toContain("export SURROGATE_PAIR='𝕳𝖊𝖑𝖑𝖔'"); 215 | expect(output).toContain("export RTL_TEXT='مرحبا mixed עברית'"); 216 | expect(output).toContain("export COMBINING='éé'"); 217 | expect(output).toContain("export CONTROL_CHARS='test\u0001\u0002\u0003test'"); 218 | expect(output).toContain("export EMOJI_ZWJ='👨👩👧👦'"); 219 | // Invalid surrogate gets replaced with replacement character 220 | expect(output).toContain("export INVALID_SURROGATE='test�test'"); 221 | }); 222 | }); 223 | 224 | describe('Environment variable edge cases', () => { 225 | it('should handle environment variable name transformations', () => { 226 | const config = { 227 | "lowercase": "value", 228 | "UPPERCASE": "value", 229 | "camelCase": "value", 230 | "PascalCase": "value", 231 | "snake_case": "value", 232 | "kebab-case": "value", 233 | "dot.notation": "value", 234 | "space separated": "value", 235 | "special!@#$%^&*()": "value", 236 | "123starting-with-number": "value", 237 | "ending-with-number123": "value", 238 | "-starting-with-dash": "value", 239 | "_starting_with_underscore": "value" 240 | }; 241 | fs.writeFileSync(configPath, JSON.stringify(config)); 242 | 243 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 244 | 245 | // Check transformations 246 | expect(output).toContain("export LOWERCASE='value'"); 247 | expect(output).toContain("export UPPERCASE='value'"); 248 | expect(output).toContain("export CAMELCASE='value'"); 249 | expect(output).toContain("export PASCALCASE='value'"); 250 | expect(output).toContain("export SNAKE_CASE='value'"); 251 | expect(output).toContain("export KEBAB_CASE='value'"); 252 | expect(output).toContain("export DOT_NOTATION='value'"); 253 | expect(output).toContain("export SPACE_SEPARATED='value'"); 254 | expect(output).toContain("export SPECIAL='value'"); // special chars removed 255 | expect(output).toContain("export _123STARTING_WITH_NUMBER='value'"); // prefixed 256 | expect(output).toContain("export ENDING_WITH_NUMBER123='value'"); 257 | expect(output).toContain("export STARTING_WITH_DASH='value'"); // dash removed 258 | expect(output).toContain("export STARTING_WITH_UNDERSCORE='value'"); // Leading underscore is trimmed 259 | }); 260 | 261 | it('should handle conflicting keys after transformation', () => { 262 | const config = { 263 | "test_key": "underscore", 264 | "test-key": "dash", 265 | "test.key": "dot", 266 | "test key": "space", 267 | "TEST_KEY": "uppercase", 268 | nested: { 269 | "test_key": "nested_underscore" 270 | } 271 | }; 272 | fs.writeFileSync(configPath, JSON.stringify(config)); 273 | 274 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 275 | 276 | // All should be transformed to TEST_KEY 277 | const lines = output.trim().split('\n'); 278 | const testKeyLines = lines.filter(line => line.includes("TEST_KEY='")); 279 | 280 | // Script outputs all unique TEST_KEY values it encounters 281 | // The parser processes keys in order, outputting each unique var name once 282 | expect(testKeyLines.length).toBeGreaterThanOrEqual(1); 283 | 284 | // Nested one has different prefix 285 | expect(output).toContain("export NESTED_TEST_KEY='nested_underscore'"); 286 | }); 287 | }); 288 | 289 | describe('Performance edge cases', () => { 290 | it('should handle extremely deep nesting efficiently', () => { 291 | // Create very deep nesting (script allows up to depth 10, which is 11 levels) 292 | const createDeepNested = (depth: number, value: any = "deep_value"): any => { 293 | if (depth === 0) return value; 294 | return { nested: createDeepNested(depth - 1, value) }; 295 | }; 296 | 297 | // Create nested object with exactly 10 levels 298 | const config = createDeepNested(10); 299 | fs.writeFileSync(configPath, JSON.stringify(config)); 300 | 301 | const start = Date.now(); 302 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 303 | const duration = Date.now() - start; 304 | 305 | // Should complete in reasonable time even with deep nesting 306 | expect(duration).toBeLessThan(1000); // Less than 1 second 307 | 308 | // Should produce the deeply nested key with 10 levels 309 | const expectedKey = Array(10).fill('NESTED').join('_'); 310 | expect(output).toContain(`export ${expectedKey}='deep_value'`); 311 | 312 | // Test that 11 levels also works (script allows up to depth 10 = 11 levels) 313 | const deepConfig = createDeepNested(11); 314 | fs.writeFileSync(configPath, JSON.stringify(deepConfig)); 315 | const output2 = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 316 | const elevenLevelKey = Array(11).fill('NESTED').join('_'); 317 | expect(output2).toContain(`export ${elevenLevelKey}='deep_value'`); // 11 levels present 318 | 319 | // Test that 12 levels gets completely blocked (beyond depth limit) 320 | const veryDeepConfig = createDeepNested(12); 321 | fs.writeFileSync(configPath, JSON.stringify(veryDeepConfig)); 322 | const output3 = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 323 | // With 12 levels, recursion limit is exceeded and no output is produced 324 | expect(output3).toBe(''); // No output at all 325 | }); 326 | 327 | it('should handle wide objects efficiently', () => { 328 | // Create object with many keys at same level 329 | const config: Record<string, any> = {}; 330 | for (let i = 0; i < 1000; i++) { 331 | config[`key_${i}`] = { 332 | nested_a: `value_a_${i}`, 333 | nested_b: `value_b_${i}`, 334 | nested_c: { 335 | deep: `deep_${i}` 336 | } 337 | }; 338 | } 339 | fs.writeFileSync(configPath, JSON.stringify(config)); 340 | 341 | const start = Date.now(); 342 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 343 | const duration = Date.now() - start; 344 | 345 | // Should complete efficiently 346 | expect(duration).toBeLessThan(2000); // Less than 2 seconds 347 | 348 | const lines = output.trim().split('\n'); 349 | expect(lines.length).toBe(3000); // 3 values per key × 1000 keys (nested_c.deep is flattened) 350 | 351 | // Verify format 352 | expect(output).toContain("export KEY_0_NESTED_A='value_a_0'"); 353 | expect(output).toContain("export KEY_999_NESTED_C_DEEP='deep_999'"); 354 | }); 355 | }); 356 | 357 | describe('Mixed content edge cases', () => { 358 | it('should handle mixed valid and invalid content', () => { 359 | const config = { 360 | valid_string: "normal value", 361 | valid_number: 42, 362 | valid_bool: true, 363 | invalid_undefined: undefined, 364 | invalid_function: null, // Would be a function but JSON.stringify converts to null 365 | invalid_symbol: null, // Would be a Symbol but JSON.stringify converts to null 366 | valid_nested: { 367 | inner_valid: "works", 368 | inner_array: ["ignored", "array"], 369 | inner_null: null 370 | } 371 | }; 372 | fs.writeFileSync(configPath, JSON.stringify(config)); 373 | 374 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { encoding: 'utf8' }); 375 | 376 | // Only valid values should be exported 377 | expect(output).toContain("export VALID_STRING='normal value'"); 378 | expect(output).toContain("export VALID_NUMBER='42'"); 379 | expect(output).toContain("export VALID_BOOL='true'"); 380 | expect(output).toContain("export VALID_NESTED_INNER_VALID='works'"); 381 | 382 | // null values, undefined (becomes undefined in JSON), and arrays are not exported 383 | expect(output).not.toContain('INVALID_UNDEFINED'); 384 | expect(output).not.toContain('INVALID_FUNCTION'); 385 | expect(output).not.toContain('INVALID_SYMBOL'); 386 | expect(output).not.toContain('INNER_ARRAY'); 387 | expect(output).not.toContain('INNER_NULL'); 388 | }); 389 | }); 390 | 391 | describe('Real-world configuration scenarios', () => { 392 | it('should handle typical n8n-mcp configuration', () => { 393 | const config = { 394 | mcp_mode: "http", 395 | auth_token: "bearer-token-123", 396 | server: { 397 | host: "0.0.0.0", 398 | port: 3000, 399 | cors: { 400 | enabled: true, 401 | origins: ["http://localhost:3000", "https://app.example.com"] 402 | } 403 | }, 404 | database: { 405 | node_db_path: "/data/nodes.db", 406 | template_cache_size: 100 407 | }, 408 | logging: { 409 | level: "info", 410 | format: "json", 411 | disable_console_output: false 412 | }, 413 | features: { 414 | enable_templates: true, 415 | enable_validation: true, 416 | validation_profile: "ai-friendly" 417 | } 418 | }; 419 | fs.writeFileSync(configPath, JSON.stringify(config)); 420 | 421 | // Run with a clean set of environment variables to avoid conflicts 422 | // We need to preserve PATH so node can be found 423 | const output = execSync(`node "${parseConfigPath}" "${configPath}"`, { 424 | encoding: 'utf8', 425 | env: { PATH: process.env.PATH, NODE_ENV: 'test' } // Only include PATH and NODE_ENV 426 | }); 427 | 428 | // Verify all configuration is properly exported with export prefix 429 | expect(output).toContain("export MCP_MODE='http'"); 430 | expect(output).toContain("export AUTH_TOKEN='bearer-token-123'"); 431 | expect(output).toContain("export SERVER_HOST='0.0.0.0'"); 432 | expect(output).toContain("export SERVER_PORT='3000'"); 433 | expect(output).toContain("export SERVER_CORS_ENABLED='true'"); 434 | expect(output).toContain("export DATABASE_NODE_DB_PATH='/data/nodes.db'"); 435 | expect(output).toContain("export DATABASE_TEMPLATE_CACHE_SIZE='100'"); 436 | expect(output).toContain("export LOGGING_LEVEL='info'"); 437 | expect(output).toContain("export LOGGING_FORMAT='json'"); 438 | expect(output).toContain("export LOGGING_DISABLE_CONSOLE_OUTPUT='false'"); 439 | expect(output).toContain("export FEATURES_ENABLE_TEMPLATES='true'"); 440 | expect(output).toContain("export FEATURES_ENABLE_VALIDATION='true'"); 441 | expect(output).toContain("export FEATURES_VALIDATION_PROFILE='ai-friendly'"); 442 | 443 | // Arrays should be ignored 444 | expect(output).not.toContain('ORIGINS'); 445 | }); 446 | }); 447 | }); ``` -------------------------------------------------------------------------------- /src/services/property-filter.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * PropertyFilter Service 3 | * 4 | * Intelligently filters node properties to return only essential and commonly-used ones. 5 | * Reduces property count from 200+ to 10-20 for better AI agent usability. 6 | */ 7 | 8 | export interface SimplifiedProperty { 9 | name: string; 10 | displayName: string; 11 | type: string; 12 | description: string; 13 | default?: any; 14 | options?: Array<{ value: string; label: string }>; 15 | required?: boolean; 16 | placeholder?: string; 17 | showWhen?: Record<string, any>; 18 | usageHint?: string; 19 | } 20 | 21 | export interface EssentialConfig { 22 | required: string[]; 23 | common: string[]; 24 | categoryPriority?: string[]; 25 | } 26 | 27 | export interface FilteredProperties { 28 | required: SimplifiedProperty[]; 29 | common: SimplifiedProperty[]; 30 | } 31 | 32 | export class PropertyFilter { 33 | /** 34 | * Curated lists of essential properties for the most commonly used nodes. 35 | * Based on analysis of typical workflows and AI agent needs. 36 | */ 37 | private static ESSENTIAL_PROPERTIES: Record<string, EssentialConfig> = { 38 | // HTTP Request - Most used node 39 | 'nodes-base.httpRequest': { 40 | required: ['url'], 41 | common: ['method', 'authentication', 'sendBody', 'contentType', 'sendHeaders'], 42 | categoryPriority: ['basic', 'authentication', 'request', 'response', 'advanced'] 43 | }, 44 | 45 | // Webhook - Entry point for many workflows 46 | 'nodes-base.webhook': { 47 | required: [], 48 | common: ['httpMethod', 'path', 'responseMode', 'responseData', 'responseCode'], 49 | categoryPriority: ['basic', 'response', 'advanced'] 50 | }, 51 | 52 | // Code - For custom logic 53 | 'nodes-base.code': { 54 | required: [], 55 | common: ['language', 'jsCode', 'pythonCode', 'mode'], 56 | categoryPriority: ['basic', 'code', 'advanced'] 57 | }, 58 | 59 | // Set - Data manipulation 60 | 'nodes-base.set': { 61 | required: [], 62 | common: ['mode', 'assignments', 'includeOtherFields', 'options'], 63 | categoryPriority: ['basic', 'data', 'advanced'] 64 | }, 65 | 66 | // If - Conditional logic 67 | 'nodes-base.if': { 68 | required: [], 69 | common: ['conditions', 'combineOperation'], 70 | categoryPriority: ['basic', 'conditions', 'advanced'] 71 | }, 72 | 73 | // PostgreSQL - Database operations 74 | 'nodes-base.postgres': { 75 | required: [], 76 | common: ['operation', 'table', 'query', 'additionalFields', 'returnAll'], 77 | categoryPriority: ['basic', 'query', 'options', 'advanced'] 78 | }, 79 | 80 | // OpenAI - AI operations 81 | 'nodes-base.openAi': { 82 | required: [], 83 | common: ['resource', 'operation', 'modelId', 'prompt', 'messages', 'maxTokens'], 84 | categoryPriority: ['basic', 'model', 'input', 'options', 'advanced'] 85 | }, 86 | 87 | // Google Sheets - Spreadsheet operations 88 | 'nodes-base.googleSheets': { 89 | required: [], 90 | common: ['operation', 'documentId', 'sheetName', 'range', 'dataStartRow'], 91 | categoryPriority: ['basic', 'location', 'data', 'options', 'advanced'] 92 | }, 93 | 94 | // Slack - Messaging 95 | 'nodes-base.slack': { 96 | required: [], 97 | common: ['resource', 'operation', 'channel', 'text', 'attachments', 'blocks'], 98 | categoryPriority: ['basic', 'message', 'formatting', 'advanced'] 99 | }, 100 | 101 | // Email - Email operations 102 | 'nodes-base.email': { 103 | required: [], 104 | common: ['resource', 'operation', 'fromEmail', 'toEmail', 'subject', 'text', 'html'], 105 | categoryPriority: ['basic', 'recipients', 'content', 'advanced'] 106 | }, 107 | 108 | // Merge - Combining data streams 109 | 'nodes-base.merge': { 110 | required: [], 111 | common: ['mode', 'joinMode', 'propertyName1', 'propertyName2', 'outputDataFrom'], 112 | categoryPriority: ['basic', 'merge', 'advanced'] 113 | }, 114 | 115 | // Function (legacy) - Custom functions 116 | 'nodes-base.function': { 117 | required: [], 118 | common: ['functionCode'], 119 | categoryPriority: ['basic', 'code', 'advanced'] 120 | }, 121 | 122 | // Split In Batches - Batch processing 123 | 'nodes-base.splitInBatches': { 124 | required: [], 125 | common: ['batchSize', 'options'], 126 | categoryPriority: ['basic', 'options', 'advanced'] 127 | }, 128 | 129 | // Redis - Cache operations 130 | 'nodes-base.redis': { 131 | required: [], 132 | common: ['operation', 'key', 'value', 'keyType', 'expire'], 133 | categoryPriority: ['basic', 'data', 'options', 'advanced'] 134 | }, 135 | 136 | // MongoDB - NoSQL operations 137 | 'nodes-base.mongoDb': { 138 | required: [], 139 | common: ['operation', 'collection', 'query', 'fields', 'limit'], 140 | categoryPriority: ['basic', 'query', 'options', 'advanced'] 141 | }, 142 | 143 | // MySQL - Database operations 144 | 'nodes-base.mySql': { 145 | required: [], 146 | common: ['operation', 'table', 'query', 'columns', 'additionalFields'], 147 | categoryPriority: ['basic', 'query', 'options', 'advanced'] 148 | }, 149 | 150 | // FTP - File transfer 151 | 'nodes-base.ftp': { 152 | required: [], 153 | common: ['operation', 'path', 'fileName', 'binaryData'], 154 | categoryPriority: ['basic', 'file', 'options', 'advanced'] 155 | }, 156 | 157 | // SSH - Remote execution 158 | 'nodes-base.ssh': { 159 | required: [], 160 | common: ['resource', 'operation', 'command', 'path', 'cwd'], 161 | categoryPriority: ['basic', 'command', 'options', 'advanced'] 162 | }, 163 | 164 | // Execute Command - Local execution 165 | 'nodes-base.executeCommand': { 166 | required: [], 167 | common: ['command', 'cwd'], 168 | categoryPriority: ['basic', 'advanced'] 169 | }, 170 | 171 | // GitHub - Version control operations 172 | 'nodes-base.github': { 173 | required: [], 174 | common: ['resource', 'operation', 'owner', 'repository', 'title', 'body'], 175 | categoryPriority: ['basic', 'repository', 'content', 'advanced'] 176 | } 177 | }; 178 | 179 | /** 180 | * Deduplicate properties based on name and display conditions 181 | */ 182 | static deduplicateProperties(properties: any[]): any[] { 183 | const seen = new Map<string, any>(); 184 | 185 | return properties.filter(prop => { 186 | // Skip null/undefined properties 187 | if (!prop || !prop.name) { 188 | return false; 189 | } 190 | 191 | // Create unique key from name + conditions 192 | const conditions = JSON.stringify(prop.displayOptions || {}); 193 | const key = `${prop.name}_${conditions}`; 194 | 195 | if (seen.has(key)) { 196 | return false; // Skip duplicate 197 | } 198 | 199 | seen.set(key, prop); 200 | return true; 201 | }); 202 | } 203 | 204 | /** 205 | * Get essential properties for a node type 206 | */ 207 | static getEssentials(allProperties: any[], nodeType: string): FilteredProperties { 208 | // Handle null/undefined properties 209 | if (!allProperties) { 210 | return { required: [], common: [] }; 211 | } 212 | 213 | // Deduplicate first 214 | const uniqueProperties = this.deduplicateProperties(allProperties); 215 | const config = this.ESSENTIAL_PROPERTIES[nodeType]; 216 | 217 | if (!config) { 218 | // Fallback for unconfigured nodes 219 | return this.inferEssentials(uniqueProperties); 220 | } 221 | 222 | // Extract required properties 223 | const required = this.extractProperties(uniqueProperties, config.required, true); 224 | 225 | // Extract common properties (excluding any already in required) 226 | const requiredNames = new Set(required.map(p => p.name)); 227 | const common = this.extractProperties(uniqueProperties, config.common, false) 228 | .filter(p => !requiredNames.has(p.name)); 229 | 230 | return { required, common }; 231 | } 232 | 233 | /** 234 | * Extract and simplify specified properties 235 | */ 236 | private static extractProperties( 237 | allProperties: any[], 238 | propertyNames: string[], 239 | markAsRequired: boolean 240 | ): SimplifiedProperty[] { 241 | const extracted: SimplifiedProperty[] = []; 242 | 243 | for (const name of propertyNames) { 244 | const property = this.findPropertyByName(allProperties, name); 245 | if (property) { 246 | const simplified = this.simplifyProperty(property); 247 | if (markAsRequired) { 248 | simplified.required = true; 249 | } 250 | extracted.push(simplified); 251 | } 252 | } 253 | 254 | return extracted; 255 | } 256 | 257 | /** 258 | * Find a property by name, including in nested collections 259 | */ 260 | private static findPropertyByName(properties: any[], name: string): any | undefined { 261 | for (const prop of properties) { 262 | if (prop.name === name) { 263 | return prop; 264 | } 265 | 266 | // Check in nested collections 267 | if (prop.type === 'collection' && prop.options) { 268 | const found = this.findPropertyByName(prop.options, name); 269 | if (found) return found; 270 | } 271 | 272 | // Check in fixed collections 273 | if (prop.type === 'fixedCollection' && prop.options) { 274 | for (const option of prop.options) { 275 | if (option.values) { 276 | const found = this.findPropertyByName(option.values, name); 277 | if (found) return found; 278 | } 279 | } 280 | } 281 | } 282 | 283 | return undefined; 284 | } 285 | 286 | /** 287 | * Simplify a property for AI consumption 288 | */ 289 | private static simplifyProperty(prop: any): SimplifiedProperty { 290 | const simplified: SimplifiedProperty = { 291 | name: prop.name, 292 | displayName: prop.displayName || prop.name, 293 | type: prop.type || 'string', // Default to string if no type specified 294 | description: this.extractDescription(prop), 295 | required: prop.required || false 296 | }; 297 | 298 | // Include default value if it's simple 299 | if (prop.default !== undefined && 300 | typeof prop.default !== 'object' || 301 | prop.type === 'options' || 302 | prop.type === 'multiOptions') { 303 | simplified.default = prop.default; 304 | } 305 | 306 | // Include placeholder 307 | if (prop.placeholder) { 308 | simplified.placeholder = prop.placeholder; 309 | } 310 | 311 | // Simplify options for select fields 312 | if (prop.options && Array.isArray(prop.options)) { 313 | // Limit options to first 20 for better usability 314 | const limitedOptions = prop.options.slice(0, 20); 315 | simplified.options = limitedOptions.map((opt: any) => { 316 | if (typeof opt === 'string') { 317 | return { value: opt, label: opt }; 318 | } 319 | return { 320 | value: opt.value || opt.name, 321 | label: opt.name || opt.value || opt.displayName 322 | }; 323 | }); 324 | } 325 | 326 | // Include simple display conditions (max 2 conditions) 327 | if (prop.displayOptions?.show) { 328 | const conditions = Object.keys(prop.displayOptions.show); 329 | if (conditions.length <= 2) { 330 | simplified.showWhen = prop.displayOptions.show; 331 | } 332 | } 333 | 334 | // Add usage hints based on property characteristics 335 | simplified.usageHint = this.generateUsageHint(prop); 336 | 337 | return simplified; 338 | } 339 | 340 | /** 341 | * Generate helpful usage hints for properties 342 | */ 343 | private static generateUsageHint(prop: any): string | undefined { 344 | // URL properties 345 | if (prop.name.toLowerCase().includes('url') || prop.name === 'endpoint') { 346 | return 'Enter the full URL including https://'; 347 | } 348 | 349 | // Authentication properties 350 | if (prop.name.includes('auth') || prop.name.includes('credential')) { 351 | return 'Select authentication method or credentials'; 352 | } 353 | 354 | // JSON properties 355 | if (prop.type === 'json' || prop.name.includes('json')) { 356 | return 'Enter valid JSON data'; 357 | } 358 | 359 | // Code properties 360 | if (prop.type === 'code' || prop.name.includes('code')) { 361 | return 'Enter your code here'; 362 | } 363 | 364 | // Boolean with specific behaviors 365 | if (prop.type === 'boolean' && prop.displayOptions) { 366 | return 'Enabling this will show additional options'; 367 | } 368 | 369 | return undefined; 370 | } 371 | 372 | /** 373 | * Extract description from various possible fields 374 | */ 375 | private static extractDescription(prop: any): string { 376 | // Try multiple fields where description might be stored 377 | const description = prop.description || 378 | prop.hint || 379 | prop.placeholder || 380 | prop.displayName || 381 | ''; 382 | 383 | // If still empty, generate based on property characteristics 384 | if (!description) { 385 | return this.generateDescription(prop); 386 | } 387 | 388 | return description; 389 | } 390 | 391 | /** 392 | * Generate a description based on property characteristics 393 | */ 394 | private static generateDescription(prop: any): string { 395 | const name = prop.name.toLowerCase(); 396 | const type = prop.type; 397 | 398 | // Common property descriptions 399 | const commonDescriptions: Record<string, string> = { 400 | 'url': 'The URL to make the request to', 401 | 'method': 'HTTP method to use for the request', 402 | 'authentication': 'Authentication method to use', 403 | 'sendbody': 'Whether to send a request body', 404 | 'contenttype': 'Content type of the request body', 405 | 'sendheaders': 'Whether to send custom headers', 406 | 'jsonbody': 'JSON data to send in the request body', 407 | 'headers': 'Custom headers to send with the request', 408 | 'timeout': 'Request timeout in milliseconds', 409 | 'query': 'SQL query to execute', 410 | 'table': 'Database table name', 411 | 'operation': 'Operation to perform', 412 | 'path': 'Webhook path or file path', 413 | 'httpmethod': 'HTTP method to accept', 414 | 'responsemode': 'How to respond to the webhook', 415 | 'responsecode': 'HTTP response code to return', 416 | 'channel': 'Slack channel to send message to', 417 | 'text': 'Text content of the message', 418 | 'subject': 'Email subject line', 419 | 'fromemail': 'Sender email address', 420 | 'toemail': 'Recipient email address', 421 | 'language': 'Programming language to use', 422 | 'jscode': 'JavaScript code to execute', 423 | 'pythoncode': 'Python code to execute' 424 | }; 425 | 426 | // Check for exact match 427 | if (commonDescriptions[name]) { 428 | return commonDescriptions[name]; 429 | } 430 | 431 | // Check for partial matches 432 | for (const [key, desc] of Object.entries(commonDescriptions)) { 433 | if (name.includes(key)) { 434 | return desc; 435 | } 436 | } 437 | 438 | // Type-based descriptions 439 | if (type === 'boolean') { 440 | return `Enable or disable ${prop.displayName || name}`; 441 | } else if (type === 'options') { 442 | return `Select ${prop.displayName || name}`; 443 | } else if (type === 'string') { 444 | return `Enter ${prop.displayName || name}`; 445 | } else if (type === 'number') { 446 | return `Number value for ${prop.displayName || name}`; 447 | } else if (type === 'json') { 448 | return `JSON data for ${prop.displayName || name}`; 449 | } 450 | 451 | return `Configure ${prop.displayName || name}`; 452 | } 453 | 454 | /** 455 | * Infer essentials for nodes without curated lists 456 | */ 457 | private static inferEssentials(properties: any[]): FilteredProperties { 458 | // Extract explicitly required properties (limit to prevent huge results) 459 | const required = properties 460 | .filter(p => p.name && p.required === true) 461 | .slice(0, 10) // Limit required properties 462 | .map(p => this.simplifyProperty(p)); 463 | 464 | // Find common properties (simple, always visible, at root level) 465 | const common = properties 466 | .filter(p => { 467 | return p.name && // Ensure property has a name 468 | !p.required && 469 | !p.displayOptions && 470 | p.type !== 'hidden' && // Filter out hidden properties 471 | p.type !== 'notice' && // Filter out notice properties 472 | !p.name.startsWith('options') && 473 | !p.name.startsWith('_'); // Filter out internal properties 474 | }) 475 | .slice(0, 10) // Take first 10 simple properties 476 | .map(p => this.simplifyProperty(p)); 477 | 478 | // If we have very few properties, include some conditional ones 479 | if (required.length + common.length < 10) { 480 | const additional = properties 481 | .filter(p => { 482 | return p.name && // Ensure property has a name 483 | !p.required && 484 | p.type !== 'hidden' && // Filter out hidden properties 485 | p.displayOptions && 486 | Object.keys(p.displayOptions.show || {}).length === 1; 487 | }) 488 | .slice(0, 10 - (required.length + common.length)) 489 | .map(p => this.simplifyProperty(p)); 490 | 491 | common.push(...additional); 492 | } 493 | 494 | // Total should not exceed 30 properties 495 | const totalLimit = 30; 496 | if (required.length + common.length > totalLimit) { 497 | // Prioritize required properties 498 | const requiredCount = Math.min(required.length, 15); 499 | const commonCount = totalLimit - requiredCount; 500 | return { 501 | required: required.slice(0, requiredCount), 502 | common: common.slice(0, commonCount) 503 | }; 504 | } 505 | 506 | return { required, common }; 507 | } 508 | 509 | /** 510 | * Search for properties matching a query 511 | */ 512 | static searchProperties( 513 | allProperties: any[], 514 | query: string, 515 | maxResults: number = 20 516 | ): SimplifiedProperty[] { 517 | // Return empty array for empty query 518 | if (!query || query.trim() === '') { 519 | return []; 520 | } 521 | 522 | const lowerQuery = query.toLowerCase(); 523 | const matches: Array<{ property: any; score: number; path: string }> = []; 524 | 525 | this.searchPropertiesRecursive(allProperties, lowerQuery, matches); 526 | 527 | // Sort by score and return top results 528 | return matches 529 | .sort((a, b) => b.score - a.score) 530 | .slice(0, maxResults) 531 | .map(match => ({ 532 | ...this.simplifyProperty(match.property), 533 | path: match.path 534 | } as SimplifiedProperty & { path: string })); 535 | } 536 | 537 | /** 538 | * Recursively search properties including nested ones 539 | */ 540 | private static searchPropertiesRecursive( 541 | properties: any[], 542 | query: string, 543 | matches: Array<{ property: any; score: number; path: string }>, 544 | path: string = '' 545 | ): void { 546 | for (const prop of properties) { 547 | const currentPath = path ? `${path}.${prop.name}` : prop.name; 548 | let score = 0; 549 | 550 | // Check name match 551 | if (prop.name.toLowerCase() === query) { 552 | score = 10; // Exact match 553 | } else if (prop.name.toLowerCase().startsWith(query)) { 554 | score = 8; // Prefix match 555 | } else if (prop.name.toLowerCase().includes(query)) { 556 | score = 5; // Contains match 557 | } 558 | 559 | // Check display name match 560 | if (prop.displayName?.toLowerCase().includes(query)) { 561 | score = Math.max(score, 4); 562 | } 563 | 564 | // Check description match 565 | if (prop.description?.toLowerCase().includes(query)) { 566 | score = Math.max(score, 3); 567 | } 568 | 569 | if (score > 0) { 570 | matches.push({ property: prop, score, path: currentPath }); 571 | } 572 | 573 | // Search nested properties 574 | if (prop.type === 'collection' && prop.options) { 575 | this.searchPropertiesRecursive(prop.options, query, matches, currentPath); 576 | } else if (prop.type === 'fixedCollection' && prop.options) { 577 | for (const option of prop.options) { 578 | if (option.values) { 579 | this.searchPropertiesRecursive( 580 | option.values, 581 | query, 582 | matches, 583 | `${currentPath}.${option.name}` 584 | ); 585 | } 586 | } 587 | } 588 | } 589 | } 590 | } ``` -------------------------------------------------------------------------------- /src/services/operation-similarity-service.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { NodeRepository } from '../database/node-repository'; 2 | import { logger } from '../utils/logger'; 3 | import { ValidationServiceError } from '../errors/validation-service-error'; 4 | 5 | export interface OperationSuggestion { 6 | value: string; 7 | confidence: number; 8 | reason: string; 9 | resource?: string; 10 | description?: string; 11 | } 12 | 13 | interface OperationPattern { 14 | pattern: string; 15 | suggestion: string; 16 | confidence: number; 17 | reason: string; 18 | } 19 | 20 | export class OperationSimilarityService { 21 | private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes 22 | private static readonly MIN_CONFIDENCE = 0.3; // 30% minimum confidence to suggest 23 | private static readonly MAX_SUGGESTIONS = 5; 24 | 25 | // Confidence thresholds for better code clarity 26 | private static readonly CONFIDENCE_THRESHOLDS = { 27 | EXACT: 1.0, 28 | VERY_HIGH: 0.95, 29 | HIGH: 0.8, 30 | MEDIUM: 0.6, 31 | MIN_SUBSTRING: 0.7 32 | } as const; 33 | 34 | private repository: NodeRepository; 35 | private operationCache: Map<string, { operations: any[], timestamp: number }> = new Map(); 36 | private suggestionCache: Map<string, OperationSuggestion[]> = new Map(); 37 | private commonPatterns: Map<string, OperationPattern[]>; 38 | 39 | constructor(repository: NodeRepository) { 40 | this.repository = repository; 41 | this.commonPatterns = this.initializeCommonPatterns(); 42 | } 43 | 44 | /** 45 | * Clean up expired cache entries to prevent memory leaks 46 | * Should be called periodically or before cache operations 47 | */ 48 | private cleanupExpiredEntries(): void { 49 | const now = Date.now(); 50 | 51 | // Clean operation cache 52 | for (const [key, value] of this.operationCache.entries()) { 53 | if (now - value.timestamp >= OperationSimilarityService.CACHE_DURATION_MS) { 54 | this.operationCache.delete(key); 55 | } 56 | } 57 | 58 | // Clean suggestion cache - these don't have timestamps, so clear if cache is too large 59 | if (this.suggestionCache.size > 100) { 60 | // Keep only the most recent 50 entries 61 | const entries = Array.from(this.suggestionCache.entries()); 62 | this.suggestionCache.clear(); 63 | entries.slice(-50).forEach(([key, value]) => { 64 | this.suggestionCache.set(key, value); 65 | }); 66 | } 67 | } 68 | 69 | /** 70 | * Initialize common operation mistake patterns 71 | */ 72 | private initializeCommonPatterns(): Map<string, OperationPattern[]> { 73 | const patterns = new Map<string, OperationPattern[]>(); 74 | 75 | // Google Drive patterns 76 | patterns.set('googleDrive', [ 77 | { pattern: 'listFiles', suggestion: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder" to list files' }, 78 | { pattern: 'uploadFile', suggestion: 'upload', confidence: 0.95, reason: 'Use "upload" instead of "uploadFile"' }, 79 | { pattern: 'deleteFile', suggestion: 'deleteFile', confidence: 1.0, reason: 'Exact match' }, 80 | { pattern: 'downloadFile', suggestion: 'download', confidence: 0.95, reason: 'Use "download" instead of "downloadFile"' }, 81 | { pattern: 'getFile', suggestion: 'download', confidence: 0.8, reason: 'Use "download" to retrieve file content' }, 82 | { pattern: 'listFolders', suggestion: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder"' }, 83 | ]); 84 | 85 | // Slack patterns 86 | patterns.set('slack', [ 87 | { pattern: 'sendMessage', suggestion: 'send', confidence: 0.95, reason: 'Use "send" instead of "sendMessage"' }, 88 | { pattern: 'getMessage', suggestion: 'get', confidence: 0.9, reason: 'Use "get" to retrieve messages' }, 89 | { pattern: 'postMessage', suggestion: 'send', confidence: 0.9, reason: 'Use "send" to post messages' }, 90 | { pattern: 'deleteMessage', suggestion: 'delete', confidence: 0.95, reason: 'Use "delete" instead of "deleteMessage"' }, 91 | { pattern: 'createChannel', suggestion: 'create', confidence: 0.9, reason: 'Use "create" with resource: "channel"' }, 92 | ]); 93 | 94 | // Database patterns (postgres, mysql, mongodb) 95 | patterns.set('database', [ 96 | { pattern: 'selectData', suggestion: 'select', confidence: 0.95, reason: 'Use "select" instead of "selectData"' }, 97 | { pattern: 'insertData', suggestion: 'insert', confidence: 0.95, reason: 'Use "insert" instead of "insertData"' }, 98 | { pattern: 'updateData', suggestion: 'update', confidence: 0.95, reason: 'Use "update" instead of "updateData"' }, 99 | { pattern: 'deleteData', suggestion: 'delete', confidence: 0.95, reason: 'Use "delete" instead of "deleteData"' }, 100 | { pattern: 'query', suggestion: 'select', confidence: 0.7, reason: 'Use "select" for queries' }, 101 | { pattern: 'fetch', suggestion: 'select', confidence: 0.7, reason: 'Use "select" to fetch data' }, 102 | ]); 103 | 104 | // HTTP patterns 105 | patterns.set('httpRequest', [ 106 | { pattern: 'fetch', suggestion: 'GET', confidence: 0.8, reason: 'Use "GET" method for fetching data' }, 107 | { pattern: 'send', suggestion: 'POST', confidence: 0.7, reason: 'Use "POST" method for sending data' }, 108 | { pattern: 'create', suggestion: 'POST', confidence: 0.8, reason: 'Use "POST" method for creating resources' }, 109 | { pattern: 'update', suggestion: 'PUT', confidence: 0.8, reason: 'Use "PUT" method for updating resources' }, 110 | { pattern: 'delete', suggestion: 'DELETE', confidence: 0.9, reason: 'Use "DELETE" method' }, 111 | ]); 112 | 113 | // Generic patterns 114 | patterns.set('generic', [ 115 | { pattern: 'list', suggestion: 'get', confidence: 0.6, reason: 'Consider using "get" or "search"' }, 116 | { pattern: 'retrieve', suggestion: 'get', confidence: 0.8, reason: 'Use "get" to retrieve data' }, 117 | { pattern: 'fetch', suggestion: 'get', confidence: 0.8, reason: 'Use "get" to fetch data' }, 118 | { pattern: 'remove', suggestion: 'delete', confidence: 0.85, reason: 'Use "delete" to remove items' }, 119 | { pattern: 'add', suggestion: 'create', confidence: 0.7, reason: 'Use "create" to add new items' }, 120 | ]); 121 | 122 | return patterns; 123 | } 124 | 125 | /** 126 | * Find similar operations for an invalid operation using Levenshtein distance 127 | * and pattern matching algorithms 128 | * 129 | * @param nodeType - The n8n node type (e.g., 'nodes-base.slack') 130 | * @param invalidOperation - The invalid operation provided by the user 131 | * @param resource - Optional resource to filter operations 132 | * @param maxSuggestions - Maximum number of suggestions to return (default: 5) 133 | * @returns Array of operation suggestions sorted by confidence 134 | * 135 | * @example 136 | * findSimilarOperations('nodes-base.googleDrive', 'listFiles', 'fileFolder') 137 | * // Returns: [{ value: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder" to list files' }] 138 | */ 139 | findSimilarOperations( 140 | nodeType: string, 141 | invalidOperation: string, 142 | resource?: string, 143 | maxSuggestions: number = OperationSimilarityService.MAX_SUGGESTIONS 144 | ): OperationSuggestion[] { 145 | // Clean up expired cache entries periodically 146 | if (Math.random() < 0.1) { // 10% chance to cleanup on each call 147 | this.cleanupExpiredEntries(); 148 | } 149 | // Check cache first 150 | const cacheKey = `${nodeType}:${invalidOperation}:${resource || ''}`; 151 | if (this.suggestionCache.has(cacheKey)) { 152 | return this.suggestionCache.get(cacheKey)!; 153 | } 154 | 155 | const suggestions: OperationSuggestion[] = []; 156 | 157 | // Get valid operations for the node 158 | let nodeInfo; 159 | try { 160 | nodeInfo = this.repository.getNode(nodeType); 161 | if (!nodeInfo) { 162 | return []; 163 | } 164 | } catch (error) { 165 | logger.warn(`Error getting node ${nodeType}:`, error); 166 | return []; 167 | } 168 | 169 | const validOperations = this.getNodeOperations(nodeType, resource); 170 | 171 | // Early termination for exact match - no suggestions needed 172 | for (const op of validOperations) { 173 | const opValue = this.getOperationValue(op); 174 | if (opValue.toLowerCase() === invalidOperation.toLowerCase()) { 175 | return []; // Valid operation, no suggestions needed 176 | } 177 | } 178 | 179 | // Check for exact pattern matches first 180 | const nodePatterns = this.getNodePatterns(nodeType); 181 | for (const pattern of nodePatterns) { 182 | if (pattern.pattern.toLowerCase() === invalidOperation.toLowerCase()) { 183 | // Type-safe operation value extraction 184 | const exists = validOperations.some(op => { 185 | const opValue = this.getOperationValue(op); 186 | return opValue === pattern.suggestion; 187 | }); 188 | if (exists) { 189 | suggestions.push({ 190 | value: pattern.suggestion, 191 | confidence: pattern.confidence, 192 | reason: pattern.reason, 193 | resource 194 | }); 195 | } 196 | } 197 | } 198 | 199 | // Calculate similarity for all valid operations 200 | for (const op of validOperations) { 201 | const opValue = this.getOperationValue(op); 202 | 203 | const similarity = this.calculateSimilarity(invalidOperation, opValue); 204 | 205 | if (similarity >= OperationSimilarityService.MIN_CONFIDENCE) { 206 | // Don't add if already suggested by pattern 207 | if (!suggestions.some(s => s.value === opValue)) { 208 | suggestions.push({ 209 | value: opValue, 210 | confidence: similarity, 211 | reason: this.getSimilarityReason(similarity, invalidOperation, opValue), 212 | resource: typeof op === 'object' ? op.resource : undefined, 213 | description: typeof op === 'object' ? (op.description || op.name) : undefined 214 | }); 215 | } 216 | } 217 | } 218 | 219 | // Sort by confidence and limit 220 | suggestions.sort((a, b) => b.confidence - a.confidence); 221 | const topSuggestions = suggestions.slice(0, maxSuggestions); 222 | 223 | // Cache the result 224 | this.suggestionCache.set(cacheKey, topSuggestions); 225 | 226 | return topSuggestions; 227 | } 228 | 229 | /** 230 | * Type-safe extraction of operation value from various formats 231 | * @param op - Operation object or string 232 | * @returns The operation value as a string 233 | */ 234 | private getOperationValue(op: any): string { 235 | if (typeof op === 'string') { 236 | return op; 237 | } 238 | if (typeof op === 'object' && op !== null) { 239 | return op.operation || op.value || ''; 240 | } 241 | return ''; 242 | } 243 | 244 | /** 245 | * Type-safe extraction of resource value 246 | * @param resource - Resource object or string 247 | * @returns The resource value as a string 248 | */ 249 | private getResourceValue(resource: any): string { 250 | if (typeof resource === 'string') { 251 | return resource; 252 | } 253 | if (typeof resource === 'object' && resource !== null) { 254 | return resource.value || ''; 255 | } 256 | return ''; 257 | } 258 | 259 | /** 260 | * Get operations for a node, handling resource filtering 261 | */ 262 | private getNodeOperations(nodeType: string, resource?: string): any[] { 263 | // Cleanup cache periodically 264 | if (Math.random() < 0.05) { // 5% chance 265 | this.cleanupExpiredEntries(); 266 | } 267 | 268 | const cacheKey = `${nodeType}:${resource || 'all'}`; 269 | const cached = this.operationCache.get(cacheKey); 270 | 271 | if (cached && Date.now() - cached.timestamp < OperationSimilarityService.CACHE_DURATION_MS) { 272 | return cached.operations; 273 | } 274 | 275 | const nodeInfo = this.repository.getNode(nodeType); 276 | if (!nodeInfo) return []; 277 | 278 | let operations: any[] = []; 279 | 280 | // Parse operations from the node with safe JSON parsing 281 | try { 282 | const opsData = nodeInfo.operations; 283 | if (typeof opsData === 'string') { 284 | // Safe JSON parsing 285 | try { 286 | operations = JSON.parse(opsData); 287 | } catch (parseError) { 288 | logger.error(`JSON parse error for operations in ${nodeType}:`, parseError); 289 | throw ValidationServiceError.jsonParseError(nodeType, parseError as Error); 290 | } 291 | } else if (Array.isArray(opsData)) { 292 | operations = opsData; 293 | } else if (opsData && typeof opsData === 'object') { 294 | operations = Object.values(opsData).flat(); 295 | } 296 | } catch (error) { 297 | // Re-throw ValidationServiceError, log and continue for others 298 | if (error instanceof ValidationServiceError) { 299 | throw error; 300 | } 301 | logger.warn(`Failed to process operations for ${nodeType}:`, error); 302 | } 303 | 304 | // Also check properties for operation fields 305 | try { 306 | const properties = nodeInfo.properties || []; 307 | for (const prop of properties) { 308 | if (prop.name === 'operation' && prop.options) { 309 | // Filter by resource if specified 310 | if (prop.displayOptions?.show?.resource) { 311 | const allowedResources = Array.isArray(prop.displayOptions.show.resource) 312 | ? prop.displayOptions.show.resource 313 | : [prop.displayOptions.show.resource]; 314 | // Only filter if a specific resource is requested 315 | if (resource && !allowedResources.includes(resource)) { 316 | continue; 317 | } 318 | // If no resource specified, include all operations 319 | } 320 | 321 | operations.push(...prop.options.map((opt: any) => ({ 322 | operation: opt.value, 323 | name: opt.name, 324 | description: opt.description, 325 | resource 326 | }))); 327 | } 328 | } 329 | } catch (error) { 330 | logger.warn(`Failed to extract operations from properties for ${nodeType}:`, error); 331 | } 332 | 333 | // Cache and return 334 | this.operationCache.set(cacheKey, { operations, timestamp: Date.now() }); 335 | return operations; 336 | } 337 | 338 | /** 339 | * Get patterns for a specific node type 340 | */ 341 | private getNodePatterns(nodeType: string): OperationPattern[] { 342 | const patterns: OperationPattern[] = []; 343 | 344 | // Add node-specific patterns 345 | if (nodeType.includes('googleDrive')) { 346 | patterns.push(...(this.commonPatterns.get('googleDrive') || [])); 347 | } else if (nodeType.includes('slack')) { 348 | patterns.push(...(this.commonPatterns.get('slack') || [])); 349 | } else if (nodeType.includes('postgres') || nodeType.includes('mysql') || nodeType.includes('mongodb')) { 350 | patterns.push(...(this.commonPatterns.get('database') || [])); 351 | } else if (nodeType.includes('httpRequest')) { 352 | patterns.push(...(this.commonPatterns.get('httpRequest') || [])); 353 | } 354 | 355 | // Always add generic patterns 356 | patterns.push(...(this.commonPatterns.get('generic') || [])); 357 | 358 | return patterns; 359 | } 360 | 361 | /** 362 | * Calculate similarity between two strings using Levenshtein distance 363 | */ 364 | private calculateSimilarity(str1: string, str2: string): number { 365 | const s1 = str1.toLowerCase(); 366 | const s2 = str2.toLowerCase(); 367 | 368 | // Exact match 369 | if (s1 === s2) return 1.0; 370 | 371 | // One is substring of the other 372 | if (s1.includes(s2) || s2.includes(s1)) { 373 | const ratio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); 374 | return Math.max(OperationSimilarityService.CONFIDENCE_THRESHOLDS.MIN_SUBSTRING, ratio); 375 | } 376 | 377 | // Calculate Levenshtein distance 378 | const distance = this.levenshteinDistance(s1, s2); 379 | const maxLength = Math.max(s1.length, s2.length); 380 | 381 | // Convert distance to similarity (0 to 1) 382 | let similarity = 1 - (distance / maxLength); 383 | 384 | // Boost confidence for single character typos and transpositions in short words 385 | if (distance === 1 && maxLength <= 5) { 386 | similarity = Math.max(similarity, 0.75); 387 | } else if (distance === 2 && maxLength <= 5) { 388 | // Boost for transpositions 389 | similarity = Math.max(similarity, 0.72); 390 | } 391 | 392 | // Boost similarity for common patterns 393 | if (this.areCommonVariations(s1, s2)) { 394 | return Math.min(1.0, similarity + 0.2); 395 | } 396 | 397 | return similarity; 398 | } 399 | 400 | /** 401 | * Calculate Levenshtein distance between two strings 402 | */ 403 | private levenshteinDistance(str1: string, str2: string): number { 404 | const m = str1.length; 405 | const n = str2.length; 406 | const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); 407 | 408 | for (let i = 0; i <= m; i++) dp[i][0] = i; 409 | for (let j = 0; j <= n; j++) dp[0][j] = j; 410 | 411 | for (let i = 1; i <= m; i++) { 412 | for (let j = 1; j <= n; j++) { 413 | if (str1[i - 1] === str2[j - 1]) { 414 | dp[i][j] = dp[i - 1][j - 1]; 415 | } else { 416 | dp[i][j] = Math.min( 417 | dp[i - 1][j] + 1, // deletion 418 | dp[i][j - 1] + 1, // insertion 419 | dp[i - 1][j - 1] + 1 // substitution 420 | ); 421 | } 422 | } 423 | } 424 | 425 | return dp[m][n]; 426 | } 427 | 428 | /** 429 | * Check if two strings are common variations 430 | */ 431 | private areCommonVariations(str1: string, str2: string): boolean { 432 | // Handle edge cases first 433 | if (str1 === '' || str2 === '' || str1 === str2) { 434 | return false; 435 | } 436 | 437 | // Check for common prefixes/suffixes 438 | const commonPrefixes = ['get', 'set', 'create', 'delete', 'update', 'send', 'fetch']; 439 | const commonSuffixes = ['data', 'item', 'record', 'message', 'file', 'folder']; 440 | 441 | for (const prefix of commonPrefixes) { 442 | if ((str1.startsWith(prefix) && !str2.startsWith(prefix)) || 443 | (!str1.startsWith(prefix) && str2.startsWith(prefix))) { 444 | const s1Clean = str1.startsWith(prefix) ? str1.slice(prefix.length) : str1; 445 | const s2Clean = str2.startsWith(prefix) ? str2.slice(prefix.length) : str2; 446 | // Only return true if at least one string was actually cleaned (not empty after cleaning) 447 | if ((str1.startsWith(prefix) && s1Clean !== str1) || (str2.startsWith(prefix) && s2Clean !== str2)) { 448 | if (s1Clean === s2Clean || this.levenshteinDistance(s1Clean, s2Clean) <= 2) { 449 | return true; 450 | } 451 | } 452 | } 453 | } 454 | 455 | for (const suffix of commonSuffixes) { 456 | if ((str1.endsWith(suffix) && !str2.endsWith(suffix)) || 457 | (!str1.endsWith(suffix) && str2.endsWith(suffix))) { 458 | const s1Clean = str1.endsWith(suffix) ? str1.slice(0, -suffix.length) : str1; 459 | const s2Clean = str2.endsWith(suffix) ? str2.slice(0, -suffix.length) : str2; 460 | // Only return true if at least one string was actually cleaned (not empty after cleaning) 461 | if ((str1.endsWith(suffix) && s1Clean !== str1) || (str2.endsWith(suffix) && s2Clean !== str2)) { 462 | if (s1Clean === s2Clean || this.levenshteinDistance(s1Clean, s2Clean) <= 2) { 463 | return true; 464 | } 465 | } 466 | } 467 | } 468 | 469 | return false; 470 | } 471 | 472 | /** 473 | * Generate a human-readable reason for the similarity 474 | * @param confidence - Similarity confidence score 475 | * @param invalid - The invalid operation string 476 | * @param valid - The valid operation string 477 | * @returns Human-readable explanation of the similarity 478 | */ 479 | private getSimilarityReason(confidence: number, invalid: string, valid: string): string { 480 | const { VERY_HIGH, HIGH, MEDIUM } = OperationSimilarityService.CONFIDENCE_THRESHOLDS; 481 | 482 | if (confidence >= VERY_HIGH) { 483 | return 'Almost exact match - likely a typo'; 484 | } else if (confidence >= HIGH) { 485 | return 'Very similar - common variation'; 486 | } else if (confidence >= MEDIUM) { 487 | return 'Similar operation'; 488 | } else if (invalid.includes(valid) || valid.includes(invalid)) { 489 | return 'Partial match'; 490 | } else { 491 | return 'Possibly related operation'; 492 | } 493 | } 494 | 495 | /** 496 | * Clear caches 497 | */ 498 | clearCache(): void { 499 | this.operationCache.clear(); 500 | this.suggestionCache.clear(); 501 | } 502 | } ``` -------------------------------------------------------------------------------- /tests/integration/database/test-utils.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import Database from 'better-sqlite3'; 4 | import { execSync } from 'child_process'; 5 | import type { DatabaseAdapter } from '../../../src/database/database-adapter'; 6 | 7 | /** 8 | * Configuration options for creating test databases 9 | */ 10 | export interface TestDatabaseOptions { 11 | /** Database mode - in-memory for fast tests, file for persistence tests */ 12 | mode: 'memory' | 'file'; 13 | /** Custom database filename (only for file mode) */ 14 | name?: string; 15 | /** Enable Write-Ahead Logging for better concurrency (file mode only) */ 16 | enableWAL?: boolean; 17 | /** Enable FTS5 full-text search extension */ 18 | enableFTS5?: boolean; 19 | } 20 | 21 | /** 22 | * Test database utility for creating isolated database instances for testing. 23 | * Provides automatic schema setup, cleanup, and various helper methods. 24 | * 25 | * @example 26 | * ```typescript 27 | * // Create in-memory database for unit tests 28 | * const testDb = await TestDatabase.createIsolated({ mode: 'memory' }); 29 | * const db = testDb.getDatabase(); 30 | * // ... run tests 31 | * await testDb.cleanup(); 32 | * 33 | * // Create file-based database for integration tests 34 | * const testDb = await TestDatabase.createIsolated({ 35 | * mode: 'file', 36 | * enableWAL: true 37 | * }); 38 | * ``` 39 | */ 40 | export class TestDatabase { 41 | private db: Database.Database | null = null; 42 | private dbPath?: string; 43 | private options: TestDatabaseOptions; 44 | 45 | constructor(options: TestDatabaseOptions = { mode: 'memory' }) { 46 | this.options = options; 47 | } 48 | 49 | /** 50 | * Creates an isolated test database instance with automatic cleanup. 51 | * Each instance gets a unique name to prevent conflicts in parallel tests. 52 | * 53 | * @param options - Database configuration options 54 | * @returns Promise resolving to initialized TestDatabase instance 55 | */ 56 | static async createIsolated(options: TestDatabaseOptions = { mode: 'memory' }): Promise<TestDatabase> { 57 | const testDb = new TestDatabase({ 58 | ...options, 59 | name: options.name || `isolated-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.db` 60 | }); 61 | await testDb.initialize(); 62 | return testDb; 63 | } 64 | 65 | async initialize(): Promise<Database.Database> { 66 | if (this.db) return this.db; 67 | 68 | if (this.options.mode === 'file') { 69 | const testDir = path.join(__dirname, '../../../.test-dbs'); 70 | if (!fs.existsSync(testDir)) { 71 | fs.mkdirSync(testDir, { recursive: true }); 72 | } 73 | this.dbPath = path.join(testDir, this.options.name || `test-${Date.now()}.db`); 74 | this.db = new Database(this.dbPath); 75 | } else { 76 | this.db = new Database(':memory:'); 77 | } 78 | 79 | // Enable WAL mode for file databases 80 | if (this.options.mode === 'file' && this.options.enableWAL !== false) { 81 | this.db.exec('PRAGMA journal_mode = WAL'); 82 | } 83 | 84 | // Load FTS5 extension if requested 85 | if (this.options.enableFTS5) { 86 | // FTS5 is built into SQLite by default in better-sqlite3 87 | try { 88 | this.db.exec('CREATE VIRTUAL TABLE test_fts USING fts5(content)'); 89 | this.db.exec('DROP TABLE test_fts'); 90 | } catch (error) { 91 | throw new Error('FTS5 extension not available'); 92 | } 93 | } 94 | 95 | // Apply schema 96 | await this.applySchema(); 97 | 98 | return this.db; 99 | } 100 | 101 | private async applySchema(): Promise<void> { 102 | if (!this.db) throw new Error('Database not initialized'); 103 | 104 | const schemaPath = path.join(__dirname, '../../../src/database/schema.sql'); 105 | const schema = fs.readFileSync(schemaPath, 'utf-8'); 106 | 107 | // Parse SQL statements properly (handles BEGIN...END blocks in triggers) 108 | const statements = this.parseSQLStatements(schema); 109 | 110 | for (const statement of statements) { 111 | this.db.exec(statement); 112 | } 113 | } 114 | 115 | /** 116 | * Parse SQL statements from schema file, properly handling multi-line statements 117 | * including triggers with BEGIN...END blocks 118 | */ 119 | private parseSQLStatements(sql: string): string[] { 120 | const statements: string[] = []; 121 | let current = ''; 122 | let inBlock = false; 123 | 124 | const lines = sql.split('\n'); 125 | 126 | for (const line of lines) { 127 | const trimmed = line.trim().toUpperCase(); 128 | 129 | // Skip comments and empty lines 130 | if (trimmed.startsWith('--') || trimmed === '') { 131 | continue; 132 | } 133 | 134 | // Track BEGIN...END blocks (triggers, procedures) 135 | if (trimmed.includes('BEGIN')) { 136 | inBlock = true; 137 | } 138 | 139 | current += line + '\n'; 140 | 141 | // End of block (trigger/procedure) 142 | if (inBlock && trimmed === 'END;') { 143 | statements.push(current.trim()); 144 | current = ''; 145 | inBlock = false; 146 | continue; 147 | } 148 | 149 | // Regular statement end (not in block) 150 | if (!inBlock && trimmed.endsWith(';')) { 151 | statements.push(current.trim()); 152 | current = ''; 153 | } 154 | } 155 | 156 | // Add any remaining content 157 | if (current.trim()) { 158 | statements.push(current.trim()); 159 | } 160 | 161 | return statements.filter(s => s.length > 0); 162 | } 163 | 164 | /** 165 | * Gets the underlying better-sqlite3 database instance. 166 | * @throws Error if database is not initialized 167 | * @returns The database instance 168 | */ 169 | getDatabase(): Database.Database { 170 | if (!this.db) throw new Error('Database not initialized'); 171 | return this.db; 172 | } 173 | 174 | /** 175 | * Cleans up the database connection and removes any created files. 176 | * Should be called in afterEach/afterAll hooks to prevent resource leaks. 177 | */ 178 | async cleanup(): Promise<void> { 179 | if (this.db) { 180 | this.db.close(); 181 | this.db = null; 182 | } 183 | 184 | if (this.dbPath && fs.existsSync(this.dbPath)) { 185 | fs.unlinkSync(this.dbPath); 186 | // Also remove WAL and SHM files if they exist 187 | const walPath = `${this.dbPath}-wal`; 188 | const shmPath = `${this.dbPath}-shm`; 189 | if (fs.existsSync(walPath)) fs.unlinkSync(walPath); 190 | if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath); 191 | } 192 | } 193 | 194 | /** 195 | * Checks if the database is currently locked by another process. 196 | * Useful for testing concurrent access scenarios. 197 | * 198 | * @returns true if database is locked, false otherwise 199 | */ 200 | isLocked(): boolean { 201 | if (!this.db) return false; 202 | try { 203 | this.db.exec('BEGIN IMMEDIATE'); 204 | this.db.exec('ROLLBACK'); 205 | return false; 206 | } catch (error: any) { 207 | return error.code === 'SQLITE_BUSY'; 208 | } 209 | } 210 | } 211 | 212 | /** 213 | * Performance monitoring utility for measuring test execution times. 214 | * Collects timing data and provides statistical analysis. 215 | * 216 | * @example 217 | * ```typescript 218 | * const monitor = new PerformanceMonitor(); 219 | * 220 | * // Measure single operation 221 | * const stop = monitor.start('database-query'); 222 | * await db.query('SELECT * FROM nodes'); 223 | * stop(); 224 | * 225 | * // Get statistics 226 | * const stats = monitor.getStats('database-query'); 227 | * console.log(`Average: ${stats.average}ms`); 228 | * ``` 229 | */ 230 | export class PerformanceMonitor { 231 | private measurements: Map<string, number[]> = new Map(); 232 | 233 | /** 234 | * Starts timing for a labeled operation. 235 | * Returns a function that should be called to stop timing. 236 | * 237 | * @param label - Unique label for the operation being measured 238 | * @returns Stop function to call when operation completes 239 | */ 240 | start(label: string): () => void { 241 | const startTime = process.hrtime.bigint(); 242 | return () => { 243 | const endTime = process.hrtime.bigint(); 244 | const duration = Number(endTime - startTime) / 1_000_000; // Convert to milliseconds 245 | 246 | if (!this.measurements.has(label)) { 247 | this.measurements.set(label, []); 248 | } 249 | this.measurements.get(label)!.push(duration); 250 | }; 251 | } 252 | 253 | /** 254 | * Gets statistical analysis of all measurements for a given label. 255 | * 256 | * @param label - The operation label to get stats for 257 | * @returns Statistics object or null if no measurements exist 258 | */ 259 | getStats(label: string): { 260 | count: number; 261 | total: number; 262 | average: number; 263 | min: number; 264 | max: number; 265 | median: number; 266 | } | null { 267 | const durations = this.measurements.get(label); 268 | if (!durations || durations.length === 0) return null; 269 | 270 | const sorted = [...durations].sort((a, b) => a - b); 271 | const total = durations.reduce((sum, d) => sum + d, 0); 272 | 273 | return { 274 | count: durations.length, 275 | total, 276 | average: total / durations.length, 277 | min: sorted[0], 278 | max: sorted[sorted.length - 1], 279 | median: sorted[Math.floor(sorted.length / 2)] 280 | }; 281 | } 282 | 283 | /** 284 | * Clears all collected measurements. 285 | */ 286 | clear(): void { 287 | this.measurements.clear(); 288 | } 289 | } 290 | 291 | /** 292 | * Test data generator for creating mock nodes, templates, and other test objects. 293 | * Provides consistent test data with sensible defaults and easy customization. 294 | */ 295 | export class TestDataGenerator { 296 | /** 297 | * Generates a mock node object with default values and custom overrides. 298 | * 299 | * @param overrides - Properties to override in the generated node 300 | * @returns Complete node object suitable for testing 301 | * 302 | * @example 303 | * ```typescript 304 | * const node = TestDataGenerator.generateNode({ 305 | * displayName: 'Custom Node', 306 | * isAITool: true 307 | * }); 308 | * ``` 309 | */ 310 | static generateNode(overrides: any = {}): any { 311 | const nodeName = overrides.name || `testNode${Math.random().toString(36).substr(2, 9)}`; 312 | return { 313 | nodeType: overrides.nodeType || `n8n-nodes-base.${nodeName}`, 314 | packageName: overrides.packageName || overrides.package || 'n8n-nodes-base', 315 | displayName: overrides.displayName || 'Test Node', 316 | description: overrides.description || 'A test node for integration testing', 317 | category: overrides.category || 'automation', 318 | developmentStyle: overrides.developmentStyle || overrides.style || 'programmatic', 319 | isAITool: overrides.isAITool || false, 320 | isTrigger: overrides.isTrigger || false, 321 | isWebhook: overrides.isWebhook || false, 322 | isVersioned: overrides.isVersioned !== undefined ? overrides.isVersioned : true, 323 | version: overrides.version || '1', 324 | documentation: overrides.documentation || null, 325 | properties: overrides.properties || [], 326 | operations: overrides.operations || [], 327 | credentials: overrides.credentials || [], 328 | ...overrides 329 | }; 330 | } 331 | 332 | /** 333 | * Generates multiple nodes with sequential naming. 334 | * 335 | * @param count - Number of nodes to generate 336 | * @param template - Common properties to apply to all nodes 337 | * @returns Array of generated nodes 338 | */ 339 | static generateNodes(count: number, template: any = {}): any[] { 340 | return Array.from({ length: count }, (_, i) => 341 | this.generateNode({ 342 | ...template, 343 | name: `testNode${i}`, 344 | displayName: `Test Node ${i}`, 345 | nodeType: `n8n-nodes-base.testNode${i}` 346 | }) 347 | ); 348 | } 349 | 350 | /** 351 | * Generates a mock workflow template. 352 | * 353 | * @param overrides - Properties to override in the template 354 | * @returns Template object suitable for testing 355 | */ 356 | static generateTemplate(overrides: any = {}): any { 357 | return { 358 | id: Math.floor(Math.random() * 100000), 359 | name: `Test Workflow ${Math.random().toString(36).substr(2, 9)}`, 360 | totalViews: Math.floor(Math.random() * 1000), 361 | nodeTypes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'], 362 | categories: [{ id: 1, name: 'automation' }], 363 | description: 'A test workflow template', 364 | workflowInfo: { 365 | nodeCount: 5, 366 | webhookCount: 1 367 | }, 368 | ...overrides 369 | }; 370 | } 371 | 372 | /** 373 | * Generates multiple workflow templates. 374 | * 375 | * @param count - Number of templates to generate 376 | * @returns Array of template objects 377 | */ 378 | static generateTemplates(count: number): any[] { 379 | return Array.from({ length: count }, () => this.generateTemplate()); 380 | } 381 | } 382 | 383 | /** 384 | * Runs a function within a database transaction with automatic rollback on error. 385 | * Useful for testing transactional behavior and ensuring test isolation. 386 | * 387 | * @param db - Database instance 388 | * @param fn - Function to run within transaction 389 | * @returns Promise resolving to function result 390 | * @throws Rolls back transaction and rethrows any errors 391 | * 392 | * @example 393 | * ```typescript 394 | * await runInTransaction(db, () => { 395 | * db.prepare('INSERT INTO nodes ...').run(); 396 | * db.prepare('UPDATE nodes ...').run(); 397 | * // If any operation fails, all are rolled back 398 | * }); 399 | * ``` 400 | */ 401 | export async function runInTransaction<T>( 402 | db: Database.Database, 403 | fn: () => T 404 | ): Promise<T> { 405 | db.exec('BEGIN'); 406 | try { 407 | const result = await fn(); 408 | db.exec('COMMIT'); 409 | return result; 410 | } catch (error) { 411 | db.exec('ROLLBACK'); 412 | throw error; 413 | } 414 | } 415 | 416 | /** 417 | * Simulates concurrent database access using worker processes. 418 | * Useful for testing database locking and concurrency handling. 419 | * 420 | * @param dbPath - Path to the database file 421 | * @param workerCount - Number of concurrent workers to spawn 422 | * @param operations - Number of operations each worker should perform 423 | * @param workerScript - JavaScript code to execute in each worker 424 | * @returns Results with success/failure counts and total duration 425 | * 426 | * @example 427 | * ```typescript 428 | * const results = await simulateConcurrentAccess( 429 | * dbPath, 430 | * 10, // 10 workers 431 | * 100, // 100 operations each 432 | * ` 433 | * const db = require('better-sqlite3')(process.env.DB_PATH); 434 | * for (let i = 0; i < process.env.OPERATIONS; i++) { 435 | * db.prepare('INSERT INTO test VALUES (?)').run(i); 436 | * } 437 | * ` 438 | * ); 439 | * ``` 440 | */ 441 | export async function simulateConcurrentAccess( 442 | dbPath: string, 443 | workerCount: number, 444 | operations: number, 445 | workerScript: string 446 | ): Promise<{ success: number; failed: number; duration: number }> { 447 | const startTime = Date.now(); 448 | const results = { success: 0, failed: 0 }; 449 | 450 | // Create worker processes 451 | const workers = Array.from({ length: workerCount }, (_, i) => { 452 | return new Promise<void>((resolve) => { 453 | try { 454 | const output = execSync( 455 | `node -e "${workerScript}"`, 456 | { 457 | env: { 458 | ...process.env, 459 | DB_PATH: dbPath, 460 | WORKER_ID: i.toString(), 461 | OPERATIONS: operations.toString() 462 | } 463 | } 464 | ); 465 | results.success++; 466 | } catch (error) { 467 | results.failed++; 468 | } 469 | resolve(); 470 | }); 471 | }); 472 | 473 | await Promise.all(workers); 474 | 475 | return { 476 | ...results, 477 | duration: Date.now() - startTime 478 | }; 479 | } 480 | 481 | /** 482 | * Performs comprehensive database integrity checks including foreign keys and schema. 483 | * 484 | * @param db - Database instance to check 485 | * @returns Object with validation status and any error messages 486 | * 487 | * @example 488 | * ```typescript 489 | * const integrity = checkDatabaseIntegrity(db); 490 | * if (!integrity.isValid) { 491 | * console.error('Database issues:', integrity.errors); 492 | * } 493 | * ``` 494 | */ 495 | export function checkDatabaseIntegrity(db: Database.Database): { 496 | isValid: boolean; 497 | errors: string[]; 498 | } { 499 | const errors: string[] = []; 500 | 501 | try { 502 | // Run integrity check 503 | const result = db.prepare('PRAGMA integrity_check').all() as Array<{ integrity_check: string }>; 504 | if (result.length !== 1 || result[0].integrity_check !== 'ok') { 505 | errors.push('Database integrity check failed'); 506 | } 507 | 508 | // Check foreign key constraints 509 | const fkResult = db.prepare('PRAGMA foreign_key_check').all(); 510 | if (fkResult.length > 0) { 511 | errors.push(`Foreign key violations: ${JSON.stringify(fkResult)}`); 512 | } 513 | 514 | // Check table existence 515 | const tables = db.prepare(` 516 | SELECT name FROM sqlite_master 517 | WHERE type = 'table' AND name = 'nodes' 518 | `).all(); 519 | 520 | if (tables.length === 0) { 521 | errors.push('nodes table does not exist'); 522 | } 523 | 524 | } catch (error: any) { 525 | errors.push(`Integrity check error: ${error.message}`); 526 | } 527 | 528 | return { 529 | isValid: errors.length === 0, 530 | errors 531 | }; 532 | } 533 | 534 | /** 535 | * Creates a DatabaseAdapter interface from a better-sqlite3 instance. 536 | * This adapter provides a consistent interface for database operations across the codebase. 537 | * 538 | * @param db - better-sqlite3 database instance 539 | * @returns DatabaseAdapter implementation 540 | * 541 | * @example 542 | * ```typescript 543 | * const db = new Database(':memory:'); 544 | * const adapter = createTestDatabaseAdapter(db); 545 | * const stmt = adapter.prepare('SELECT * FROM nodes WHERE type = ?'); 546 | * const nodes = stmt.all('webhook'); 547 | * ``` 548 | */ 549 | export function createTestDatabaseAdapter(db: Database.Database): DatabaseAdapter { 550 | return { 551 | prepare: (sql: string) => { 552 | const stmt = db.prepare(sql); 553 | return { 554 | run: (...params: any[]) => stmt.run(...params), 555 | get: (...params: any[]) => stmt.get(...params), 556 | all: (...params: any[]) => stmt.all(...params), 557 | iterate: (...params: any[]) => stmt.iterate(...params), 558 | pluck: function(enabled?: boolean) { stmt.pluck(enabled); return this; }, 559 | expand: function(enabled?: boolean) { stmt.expand?.(enabled); return this; }, 560 | raw: function(enabled?: boolean) { stmt.raw?.(enabled); return this; }, 561 | columns: () => stmt.columns?.() || [], 562 | bind: function(...params: any[]) { stmt.bind(...params); return this; } 563 | } as any; 564 | }, 565 | exec: (sql: string) => db.exec(sql), 566 | close: () => db.close(), 567 | pragma: (key: string, value?: any) => db.pragma(key, value), 568 | get inTransaction() { return db.inTransaction; }, 569 | transaction: <T>(fn: () => T) => db.transaction(fn)(), 570 | checkFTS5Support: () => { 571 | try { 572 | db.exec('CREATE VIRTUAL TABLE test_fts5_check USING fts5(content)'); 573 | db.exec('DROP TABLE test_fts5_check'); 574 | return true; 575 | } catch { 576 | return false; 577 | } 578 | } 579 | }; 580 | } 581 | 582 | /** 583 | * Pre-configured mock nodes for common testing scenarios. 584 | * These represent the most commonly used n8n nodes with realistic configurations. 585 | */ 586 | export const MOCK_NODES = { 587 | webhook: { 588 | nodeType: 'n8n-nodes-base.webhook', 589 | packageName: 'n8n-nodes-base', 590 | displayName: 'Webhook', 591 | description: 'Starts the workflow when a webhook is called', 592 | category: 'trigger', 593 | developmentStyle: 'programmatic', 594 | isAITool: false, 595 | isTrigger: true, 596 | isWebhook: true, 597 | isVersioned: true, 598 | version: '1', 599 | documentation: 'Webhook documentation', 600 | properties: [ 601 | { 602 | displayName: 'HTTP Method', 603 | name: 'httpMethod', 604 | type: 'options', 605 | options: [ 606 | { name: 'GET', value: 'GET' }, 607 | { name: 'POST', value: 'POST' } 608 | ], 609 | default: 'GET' 610 | } 611 | ], 612 | operations: [], 613 | credentials: [] 614 | }, 615 | httpRequest: { 616 | nodeType: 'n8n-nodes-base.httpRequest', 617 | packageName: 'n8n-nodes-base', 618 | displayName: 'HTTP Request', 619 | description: 'Makes an HTTP request and returns the response', 620 | category: 'automation', 621 | developmentStyle: 'programmatic', 622 | isAITool: false, 623 | isTrigger: false, 624 | isWebhook: false, 625 | isVersioned: true, 626 | version: '1', 627 | documentation: 'HTTP Request documentation', 628 | properties: [ 629 | { 630 | displayName: 'URL', 631 | name: 'url', 632 | type: 'string', 633 | required: true, 634 | default: '' 635 | } 636 | ], 637 | operations: [], 638 | credentials: [] 639 | } 640 | }; ```