This is page 22 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 │ ├── 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 │ │ ├── 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/utils/ssrf-protection.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | 3 | // Mock dns module before importing SSRFProtection 4 | vi.mock('dns/promises', () => ({ 5 | lookup: vi.fn(), 6 | })); 7 | 8 | import { SSRFProtection } from '../../../src/utils/ssrf-protection'; 9 | import * as dns from 'dns/promises'; 10 | 11 | /** 12 | * Unit tests for SSRFProtection with configurable security modes 13 | * 14 | * SECURITY: These tests verify SSRF protection blocks malicious URLs in all modes 15 | * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-03) 16 | */ 17 | describe('SSRFProtection', () => { 18 | const originalEnv = process.env.WEBHOOK_SECURITY_MODE; 19 | 20 | beforeEach(() => { 21 | // Clear all mocks before each test 22 | vi.clearAllMocks(); 23 | // Default mock: simulate real DNS behavior - return the hostname as IP if it looks like an IP 24 | vi.mocked(dns.lookup).mockImplementation(async (hostname: any) => { 25 | // Handle special hostname "localhost" 26 | if (hostname === 'localhost') { 27 | return { address: '127.0.0.1', family: 4 } as any; 28 | } 29 | 30 | // If hostname is an IP address, return it as-is (simulating real DNS behavior) 31 | const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 32 | const ipv6Regex = /^([0-9a-fA-F]{0,4}:)+[0-9a-fA-F]{0,4}$/; 33 | 34 | if (ipv4Regex.test(hostname)) { 35 | return { address: hostname, family: 4 } as any; 36 | } 37 | if (ipv6Regex.test(hostname) || hostname === '::1') { 38 | return { address: hostname, family: 6 } as any; 39 | } 40 | 41 | // For actual hostnames, return a public IP by default 42 | return { address: '8.8.8.8', family: 4 } as any; 43 | }); 44 | }); 45 | 46 | afterEach(() => { 47 | // Restore original environment 48 | if (originalEnv) { 49 | process.env.WEBHOOK_SECURITY_MODE = originalEnv; 50 | } else { 51 | delete process.env.WEBHOOK_SECURITY_MODE; 52 | } 53 | vi.restoreAllMocks(); 54 | }); 55 | 56 | describe('Strict Mode (default)', () => { 57 | beforeEach(() => { 58 | delete process.env.WEBHOOK_SECURITY_MODE; // Use default strict 59 | }); 60 | 61 | it('should block localhost', async () => { 62 | const localhostURLs = [ 63 | 'http://localhost:3000/webhook', 64 | 'http://127.0.0.1/webhook', 65 | 'http://[::1]/webhook', 66 | ]; 67 | 68 | for (const url of localhostURLs) { 69 | const result = await SSRFProtection.validateWebhookUrl(url); 70 | expect(result.valid, `URL ${url} should be blocked but was valid`).toBe(false); 71 | expect(result.reason, `URL ${url} should have a reason`).toBeDefined(); 72 | } 73 | }); 74 | 75 | it('should block AWS metadata endpoint', async () => { 76 | const result = await SSRFProtection.validateWebhookUrl('http://169.254.169.254/latest/meta-data'); 77 | expect(result.valid).toBe(false); 78 | expect(result.reason).toContain('Cloud metadata'); 79 | }); 80 | 81 | it('should block GCP metadata endpoint', async () => { 82 | const result = await SSRFProtection.validateWebhookUrl('http://metadata.google.internal/computeMetadata/v1/'); 83 | expect(result.valid).toBe(false); 84 | expect(result.reason).toContain('Cloud metadata'); 85 | }); 86 | 87 | it('should block Alibaba Cloud metadata endpoint', async () => { 88 | const result = await SSRFProtection.validateWebhookUrl('http://100.100.100.200/latest/meta-data'); 89 | expect(result.valid).toBe(false); 90 | expect(result.reason).toContain('Cloud metadata'); 91 | }); 92 | 93 | it('should block Oracle Cloud metadata endpoint', async () => { 94 | const result = await SSRFProtection.validateWebhookUrl('http://192.0.0.192/opc/v2/instance/'); 95 | expect(result.valid).toBe(false); 96 | expect(result.reason).toContain('Cloud metadata'); 97 | }); 98 | 99 | it('should block private IP ranges', async () => { 100 | const privateIPs = [ 101 | 'http://10.0.0.1/webhook', 102 | 'http://192.168.1.1/webhook', 103 | 'http://172.16.0.1/webhook', 104 | 'http://172.31.255.255/webhook', 105 | ]; 106 | 107 | for (const url of privateIPs) { 108 | const result = await SSRFProtection.validateWebhookUrl(url); 109 | expect(result.valid).toBe(false); 110 | expect(result.reason).toContain('Private IP'); 111 | } 112 | }); 113 | 114 | it('should allow public URLs', async () => { 115 | const publicURLs = [ 116 | 'https://hooks.example.com/webhook', 117 | 'https://api.external.com/callback', 118 | 'http://public-service.com:8080/hook', 119 | ]; 120 | 121 | for (const url of publicURLs) { 122 | const result = await SSRFProtection.validateWebhookUrl(url); 123 | expect(result.valid).toBe(true); 124 | expect(result.reason).toBeUndefined(); 125 | } 126 | }); 127 | 128 | it('should block non-HTTP protocols', async () => { 129 | const invalidProtocols = [ 130 | 'file:///etc/passwd', 131 | 'ftp://internal-server/file', 132 | 'gopher://old-service', 133 | ]; 134 | 135 | for (const url of invalidProtocols) { 136 | const result = await SSRFProtection.validateWebhookUrl(url); 137 | expect(result.valid).toBe(false); 138 | expect(result.reason).toContain('protocol'); 139 | } 140 | }); 141 | }); 142 | 143 | describe('Moderate Mode', () => { 144 | beforeEach(() => { 145 | process.env.WEBHOOK_SECURITY_MODE = 'moderate'; 146 | }); 147 | 148 | it('should allow localhost', async () => { 149 | const localhostURLs = [ 150 | 'http://localhost:5678/webhook', 151 | 'http://127.0.0.1:5678/webhook', 152 | 'http://[::1]:5678/webhook', 153 | ]; 154 | 155 | for (const url of localhostURLs) { 156 | const result = await SSRFProtection.validateWebhookUrl(url); 157 | expect(result.valid).toBe(true); 158 | } 159 | }); 160 | 161 | it('should still block private IPs', async () => { 162 | const privateIPs = [ 163 | 'http://10.0.0.1/webhook', 164 | 'http://192.168.1.1/webhook', 165 | 'http://172.16.0.1/webhook', 166 | ]; 167 | 168 | for (const url of privateIPs) { 169 | const result = await SSRFProtection.validateWebhookUrl(url); 170 | expect(result.valid).toBe(false); 171 | expect(result.reason).toContain('Private IP'); 172 | } 173 | }); 174 | 175 | it('should still block cloud metadata', async () => { 176 | const metadataURLs = [ 177 | 'http://169.254.169.254/latest/meta-data', 178 | 'http://metadata.google.internal/computeMetadata/v1/', 179 | ]; 180 | 181 | for (const url of metadataURLs) { 182 | const result = await SSRFProtection.validateWebhookUrl(url); 183 | expect(result.valid).toBe(false); 184 | expect(result.reason).toContain('metadata'); 185 | } 186 | }); 187 | 188 | it('should allow public URLs', async () => { 189 | const result = await SSRFProtection.validateWebhookUrl('https://api.example.com/webhook'); 190 | expect(result.valid).toBe(true); 191 | }); 192 | }); 193 | 194 | describe('Permissive Mode', () => { 195 | beforeEach(() => { 196 | process.env.WEBHOOK_SECURITY_MODE = 'permissive'; 197 | }); 198 | 199 | it('should allow localhost', async () => { 200 | const result = await SSRFProtection.validateWebhookUrl('http://localhost:5678/webhook'); 201 | expect(result.valid).toBe(true); 202 | }); 203 | 204 | it('should allow private IPs', async () => { 205 | const privateIPs = [ 206 | 'http://10.0.0.1/webhook', 207 | 'http://192.168.1.1/webhook', 208 | 'http://172.16.0.1/webhook', 209 | ]; 210 | 211 | for (const url of privateIPs) { 212 | const result = await SSRFProtection.validateWebhookUrl(url); 213 | expect(result.valid).toBe(true); 214 | } 215 | }); 216 | 217 | it('should still block cloud metadata', async () => { 218 | const metadataURLs = [ 219 | 'http://169.254.169.254/latest/meta-data', 220 | 'http://metadata.google.internal/computeMetadata/v1/', 221 | 'http://169.254.170.2/v2/metadata', 222 | ]; 223 | 224 | for (const url of metadataURLs) { 225 | const result = await SSRFProtection.validateWebhookUrl(url); 226 | expect(result.valid).toBe(false); 227 | expect(result.reason).toContain('metadata'); 228 | } 229 | }); 230 | 231 | it('should allow public URLs', async () => { 232 | const result = await SSRFProtection.validateWebhookUrl('https://api.example.com/webhook'); 233 | expect(result.valid).toBe(true); 234 | }); 235 | }); 236 | 237 | describe('DNS Rebinding Prevention', () => { 238 | it('should block hostname resolving to private IP (strict mode)', async () => { 239 | delete process.env.WEBHOOK_SECURITY_MODE; // strict 240 | 241 | // Mock DNS lookup to return private IP 242 | vi.mocked(dns.lookup).mockResolvedValue({ address: '10.0.0.1', family: 4 } as any); 243 | 244 | const result = await SSRFProtection.validateWebhookUrl('http://evil.example.com/webhook'); 245 | expect(result.valid).toBe(false); 246 | expect(result.reason).toContain('Private IP'); 247 | }); 248 | 249 | it('should block hostname resolving to private IP (moderate mode)', async () => { 250 | process.env.WEBHOOK_SECURITY_MODE = 'moderate'; 251 | 252 | // Mock DNS lookup to return private IP 253 | vi.mocked(dns.lookup).mockResolvedValue({ address: '192.168.1.100', family: 4 } as any); 254 | 255 | const result = await SSRFProtection.validateWebhookUrl('http://internal.company.com/webhook'); 256 | expect(result.valid).toBe(false); 257 | expect(result.reason).toContain('Private IP'); 258 | }); 259 | 260 | it('should allow hostname resolving to private IP (permissive mode)', async () => { 261 | process.env.WEBHOOK_SECURITY_MODE = 'permissive'; 262 | 263 | // Mock DNS lookup to return private IP 264 | vi.mocked(dns.lookup).mockResolvedValue({ address: '192.168.1.100', family: 4 } as any); 265 | 266 | const result = await SSRFProtection.validateWebhookUrl('http://internal.company.com/webhook'); 267 | expect(result.valid).toBe(true); 268 | }); 269 | 270 | it('should block hostname resolving to cloud metadata (all modes)', async () => { 271 | const modes = ['strict', 'moderate', 'permissive']; 272 | 273 | for (const mode of modes) { 274 | process.env.WEBHOOK_SECURITY_MODE = mode; 275 | 276 | // Mock DNS lookup to return cloud metadata IP 277 | vi.mocked(dns.lookup).mockResolvedValue({ address: '169.254.169.254', family: 4 } as any); 278 | 279 | const result = await SSRFProtection.validateWebhookUrl('http://evil-domain.com/webhook'); 280 | expect(result.valid).toBe(false); 281 | expect(result.reason).toContain('metadata'); 282 | } 283 | }); 284 | 285 | it('should block hostname resolving to localhost IP (strict mode)', async () => { 286 | delete process.env.WEBHOOK_SECURITY_MODE; // strict 287 | 288 | // Mock DNS lookup to return localhost IP 289 | vi.mocked(dns.lookup).mockResolvedValue({ address: '127.0.0.1', family: 4 } as any); 290 | 291 | const result = await SSRFProtection.validateWebhookUrl('http://suspicious-domain.com/webhook'); 292 | expect(result.valid).toBe(false); 293 | expect(result.reason).toBeDefined(); 294 | }); 295 | }); 296 | 297 | describe('IPv6 Protection', () => { 298 | it('should block IPv6 localhost (strict mode)', async () => { 299 | delete process.env.WEBHOOK_SECURITY_MODE; // strict 300 | 301 | // Mock DNS to return IPv6 localhost 302 | vi.mocked(dns.lookup).mockResolvedValue({ address: '::1', family: 6 } as any); 303 | 304 | const result = await SSRFProtection.validateWebhookUrl('http://ipv6-test.com/webhook'); 305 | expect(result.valid).toBe(false); 306 | // Updated: IPv6 localhost is now caught by the localhost check, not IPv6 check 307 | expect(result.reason).toContain('Localhost'); 308 | }); 309 | 310 | it('should block IPv6 link-local (strict mode)', async () => { 311 | delete process.env.WEBHOOK_SECURITY_MODE; // strict 312 | 313 | // Mock DNS to return IPv6 link-local 314 | vi.mocked(dns.lookup).mockResolvedValue({ address: 'fe80::1', family: 6 } as any); 315 | 316 | const result = await SSRFProtection.validateWebhookUrl('http://ipv6-local.com/webhook'); 317 | expect(result.valid).toBe(false); 318 | expect(result.reason).toContain('IPv6 private'); 319 | }); 320 | 321 | it('should block IPv6 unique local (strict mode)', async () => { 322 | delete process.env.WEBHOOK_SECURITY_MODE; // strict 323 | 324 | // Mock DNS to return IPv6 unique local 325 | vi.mocked(dns.lookup).mockResolvedValue({ address: 'fc00::1', family: 6 } as any); 326 | 327 | const result = await SSRFProtection.validateWebhookUrl('http://ipv6-internal.com/webhook'); 328 | expect(result.valid).toBe(false); 329 | expect(result.reason).toContain('IPv6 private'); 330 | }); 331 | 332 | it('should block IPv6 unique local fd00::/8 (strict mode)', async () => { 333 | delete process.env.WEBHOOK_SECURITY_MODE; // strict 334 | 335 | // Mock DNS to return IPv6 unique local fd00::/8 336 | vi.mocked(dns.lookup).mockResolvedValue({ address: 'fd00::1', family: 6 } as any); 337 | 338 | const result = await SSRFProtection.validateWebhookUrl('http://ipv6-fd00.com/webhook'); 339 | expect(result.valid).toBe(false); 340 | expect(result.reason).toContain('IPv6 private'); 341 | }); 342 | 343 | it('should block IPv6 unspecified address (strict mode)', async () => { 344 | delete process.env.WEBHOOK_SECURITY_MODE; // strict 345 | 346 | // Mock DNS to return IPv6 unspecified address 347 | vi.mocked(dns.lookup).mockResolvedValue({ address: '::', family: 6 } as any); 348 | 349 | const result = await SSRFProtection.validateWebhookUrl('http://ipv6-unspecified.com/webhook'); 350 | expect(result.valid).toBe(false); 351 | expect(result.reason).toContain('IPv6 private'); 352 | }); 353 | 354 | it('should block IPv4-mapped IPv6 addresses (strict mode)', async () => { 355 | delete process.env.WEBHOOK_SECURITY_MODE; // strict 356 | 357 | // Mock DNS to return IPv4-mapped IPv6 address 358 | vi.mocked(dns.lookup).mockResolvedValue({ address: '::ffff:127.0.0.1', family: 6 } as any); 359 | 360 | const result = await SSRFProtection.validateWebhookUrl('http://ipv4-mapped.com/webhook'); 361 | expect(result.valid).toBe(false); 362 | expect(result.reason).toContain('IPv6 private'); 363 | }); 364 | }); 365 | 366 | describe('DNS Resolution Failures', () => { 367 | it('should handle DNS resolution failure gracefully', async () => { 368 | // Mock DNS lookup to fail 369 | vi.mocked(dns.lookup).mockRejectedValue(new Error('ENOTFOUND')); 370 | 371 | const result = await SSRFProtection.validateWebhookUrl('http://non-existent-domain.invalid/webhook'); 372 | expect(result.valid).toBe(false); 373 | expect(result.reason).toBe('DNS resolution failed'); 374 | }); 375 | }); 376 | 377 | describe('Edge Cases', () => { 378 | it('should handle malformed URLs', async () => { 379 | const malformedURLs = [ 380 | 'not-a-url', 381 | 'http://', 382 | '://missing-protocol.com', 383 | ]; 384 | 385 | for (const url of malformedURLs) { 386 | const result = await SSRFProtection.validateWebhookUrl(url); 387 | expect(result.valid).toBe(false); 388 | expect(result.reason).toBe('Invalid URL format'); 389 | } 390 | }); 391 | 392 | it('should handle URL with special characters safely', async () => { 393 | const result = await SSRFProtection.validateWebhookUrl('https://example.com/webhook?param=value&other=123'); 394 | expect(result.valid).toBe(true); 395 | }); 396 | }); 397 | }); 398 | ``` -------------------------------------------------------------------------------- /tests/unit/services/example-generator.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { ExampleGenerator } from '@/services/example-generator'; 3 | import type { NodeExamples } from '@/services/example-generator'; 4 | 5 | // Mock the database 6 | vi.mock('better-sqlite3'); 7 | 8 | describe('ExampleGenerator', () => { 9 | beforeEach(() => { 10 | vi.clearAllMocks(); 11 | }); 12 | 13 | describe('getExamples', () => { 14 | it('should return curated examples for HTTP Request node', () => { 15 | const examples = ExampleGenerator.getExamples('nodes-base.httpRequest'); 16 | 17 | expect(examples).toHaveProperty('minimal'); 18 | expect(examples).toHaveProperty('common'); 19 | expect(examples).toHaveProperty('advanced'); 20 | 21 | // Check minimal example 22 | expect(examples.minimal).toEqual({ 23 | url: 'https://api.example.com/data' 24 | }); 25 | 26 | // Check common example has required fields 27 | expect(examples.common).toMatchObject({ 28 | method: 'POST', 29 | url: 'https://api.example.com/users', 30 | sendBody: true, 31 | contentType: 'json' 32 | }); 33 | 34 | // Check advanced example has error handling 35 | expect(examples.advanced).toMatchObject({ 36 | method: 'POST', 37 | onError: 'continueRegularOutput', 38 | retryOnFail: true, 39 | maxTries: 3 40 | }); 41 | }); 42 | 43 | it('should return curated examples for Webhook node', () => { 44 | const examples = ExampleGenerator.getExamples('nodes-base.webhook'); 45 | 46 | expect(examples.minimal).toMatchObject({ 47 | path: 'my-webhook', 48 | httpMethod: 'POST' 49 | }); 50 | 51 | expect(examples.common).toMatchObject({ 52 | responseMode: 'lastNode', 53 | responseData: 'allEntries', 54 | responseCode: 200 55 | }); 56 | }); 57 | 58 | it('should return curated examples for Code node', () => { 59 | const examples = ExampleGenerator.getExamples('nodes-base.code'); 60 | 61 | expect(examples.minimal).toMatchObject({ 62 | language: 'javaScript', 63 | jsCode: 'return [{json: {result: "success"}}];' 64 | }); 65 | 66 | expect(examples.common?.jsCode).toContain('items.map'); 67 | expect(examples.common?.jsCode).toContain('DateTime.now()'); 68 | 69 | expect(examples.advanced?.jsCode).toContain('try'); 70 | expect(examples.advanced?.jsCode).toContain('catch'); 71 | }); 72 | 73 | it('should generate basic examples for unconfigured nodes', () => { 74 | const essentials = { 75 | required: [ 76 | { name: 'url', type: 'string' }, 77 | { name: 'method', type: 'options', options: [{ value: 'GET' }, { value: 'POST' }] } 78 | ], 79 | common: [ 80 | { name: 'timeout', type: 'number' } 81 | ] 82 | }; 83 | 84 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 85 | 86 | expect(examples.minimal).toEqual({ 87 | url: 'https://api.example.com', 88 | method: 'GET' 89 | }); 90 | 91 | expect(examples.common).toBeUndefined(); 92 | expect(examples.advanced).toBeUndefined(); 93 | }); 94 | 95 | it('should use common property if no required fields exist', () => { 96 | const essentials = { 97 | required: [], 98 | common: [ 99 | { name: 'name', type: 'string' } 100 | ] 101 | }; 102 | 103 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 104 | 105 | expect(examples.minimal).toEqual({ 106 | name: 'John Doe' 107 | }); 108 | }); 109 | 110 | it('should return empty minimal object if no essentials provided', () => { 111 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode'); 112 | 113 | expect(examples.minimal).toEqual({}); 114 | }); 115 | }); 116 | 117 | describe('special example nodes', () => { 118 | it('should provide webhook processing example', () => { 119 | const examples = ExampleGenerator.getExamples('nodes-base.code.webhookProcessing'); 120 | 121 | expect(examples.minimal?.jsCode).toContain('const webhookData = items[0].json.body'); 122 | expect(examples.minimal?.jsCode).toContain('// ❌ WRONG'); 123 | expect(examples.minimal?.jsCode).toContain('// ✅ CORRECT'); 124 | }); 125 | 126 | it('should provide data transformation examples', () => { 127 | const examples = ExampleGenerator.getExamples('nodes-base.code.dataTransform'); 128 | 129 | expect(examples.minimal?.jsCode).toContain('CSV-like data to JSON'); 130 | expect(examples.minimal?.jsCode).toContain('split'); 131 | }); 132 | 133 | it('should provide aggregation example', () => { 134 | const examples = ExampleGenerator.getExamples('nodes-base.code.aggregation'); 135 | 136 | expect(examples.minimal?.jsCode).toContain('items.reduce'); 137 | expect(examples.minimal?.jsCode).toContain('totalAmount'); 138 | }); 139 | 140 | it('should provide JMESPath filtering example', () => { 141 | const examples = ExampleGenerator.getExamples('nodes-base.code.jmespathFiltering'); 142 | 143 | expect(examples.minimal?.jsCode).toContain('$jmespath'); 144 | expect(examples.minimal?.jsCode).toContain('`100`'); // Backticks for numeric literals 145 | expect(examples.minimal?.jsCode).toContain('✅ CORRECT'); 146 | }); 147 | 148 | it('should provide Python example', () => { 149 | const examples = ExampleGenerator.getExamples('nodes-base.code.pythonExample'); 150 | 151 | expect(examples.minimal?.pythonCode).toContain('_input.all()'); 152 | expect(examples.minimal?.pythonCode).toContain('to_py()'); 153 | expect(examples.minimal?.pythonCode).toContain('import json'); 154 | }); 155 | 156 | it('should provide AI tool example', () => { 157 | const examples = ExampleGenerator.getExamples('nodes-base.code.aiTool'); 158 | 159 | expect(examples.minimal?.mode).toBe('runOnceForEachItem'); 160 | expect(examples.minimal?.jsCode).toContain('calculate discount'); 161 | expect(examples.minimal?.jsCode).toContain('$json.quantity'); 162 | }); 163 | 164 | it('should provide crypto usage example', () => { 165 | const examples = ExampleGenerator.getExamples('nodes-base.code.crypto'); 166 | 167 | expect(examples.minimal?.jsCode).toContain("require('crypto')"); 168 | expect(examples.minimal?.jsCode).toContain('randomBytes'); 169 | expect(examples.minimal?.jsCode).toContain('createHash'); 170 | }); 171 | 172 | it('should provide static data example', () => { 173 | const examples = ExampleGenerator.getExamples('nodes-base.code.staticData'); 174 | 175 | expect(examples.minimal?.jsCode).toContain('$getWorkflowStaticData'); 176 | expect(examples.minimal?.jsCode).toContain('processCount'); 177 | }); 178 | }); 179 | 180 | describe('database node examples', () => { 181 | it('should provide PostgreSQL examples', () => { 182 | const examples = ExampleGenerator.getExamples('nodes-base.postgres'); 183 | 184 | expect(examples.minimal).toMatchObject({ 185 | operation: 'executeQuery', 186 | query: 'SELECT * FROM users LIMIT 10' 187 | }); 188 | 189 | expect(examples.advanced?.query).toContain('ON CONFLICT'); 190 | expect(examples.advanced?.retryOnFail).toBe(true); 191 | }); 192 | 193 | it('should provide MongoDB examples', () => { 194 | const examples = ExampleGenerator.getExamples('nodes-base.mongoDb'); 195 | 196 | expect(examples.minimal).toMatchObject({ 197 | operation: 'find', 198 | collection: 'users' 199 | }); 200 | 201 | expect(examples.common).toMatchObject({ 202 | operation: 'findOneAndUpdate', 203 | options: { 204 | upsert: true, 205 | returnNewDocument: true 206 | } 207 | }); 208 | }); 209 | 210 | it('should provide MySQL examples', () => { 211 | const examples = ExampleGenerator.getExamples('nodes-base.mySql'); 212 | 213 | expect(examples.minimal?.query).toContain('SELECT * FROM products'); 214 | expect(examples.common?.operation).toBe('insert'); 215 | }); 216 | }); 217 | 218 | describe('communication node examples', () => { 219 | it('should provide Slack examples', () => { 220 | const examples = ExampleGenerator.getExamples('nodes-base.slack'); 221 | 222 | expect(examples.minimal).toMatchObject({ 223 | resource: 'message', 224 | operation: 'post', 225 | channel: '#general', 226 | text: 'Hello from n8n!' 227 | }); 228 | 229 | expect(examples.common?.attachments).toBeDefined(); 230 | expect(examples.common?.retryOnFail).toBe(true); 231 | }); 232 | 233 | it('should provide Email examples', () => { 234 | const examples = ExampleGenerator.getExamples('nodes-base.emailSend'); 235 | 236 | expect(examples.minimal).toMatchObject({ 237 | fromEmail: '[email protected]', 238 | toEmail: '[email protected]', 239 | subject: 'Test Email' 240 | }); 241 | 242 | expect(examples.common?.html).toContain('<h1>Welcome!</h1>'); 243 | }); 244 | }); 245 | 246 | describe('error handling patterns', () => { 247 | it('should provide modern error handling patterns', () => { 248 | const examples = ExampleGenerator.getExamples('error-handling.modern-patterns'); 249 | 250 | expect(examples.minimal).toMatchObject({ 251 | onError: 'continueRegularOutput' 252 | }); 253 | 254 | expect(examples.advanced).toMatchObject({ 255 | onError: 'stopWorkflow', 256 | retryOnFail: true, 257 | maxTries: 3 258 | }); 259 | }); 260 | 261 | it('should provide API retry patterns', () => { 262 | const examples = ExampleGenerator.getExamples('error-handling.api-with-retry'); 263 | 264 | expect(examples.common?.retryOnFail).toBe(true); 265 | expect(examples.common?.maxTries).toBe(5); 266 | expect(examples.common?.alwaysOutputData).toBe(true); 267 | }); 268 | 269 | it('should provide database error patterns', () => { 270 | const examples = ExampleGenerator.getExamples('error-handling.database-patterns'); 271 | 272 | expect(examples.common).toMatchObject({ 273 | retryOnFail: true, 274 | maxTries: 3, 275 | onError: 'stopWorkflow' 276 | }); 277 | }); 278 | 279 | it('should provide webhook error patterns', () => { 280 | const examples = ExampleGenerator.getExamples('error-handling.webhook-patterns'); 281 | 282 | expect(examples.minimal?.alwaysOutputData).toBe(true); 283 | expect(examples.common?.responseCode).toBe(200); 284 | }); 285 | }); 286 | 287 | describe('getTaskExample', () => { 288 | it('should return minimal example for basic task', () => { 289 | const example = ExampleGenerator.getTaskExample('nodes-base.httpRequest', 'basic'); 290 | 291 | expect(example).toEqual({ 292 | url: 'https://api.example.com/data' 293 | }); 294 | }); 295 | 296 | it('should return common example for typical task', () => { 297 | const example = ExampleGenerator.getTaskExample('nodes-base.httpRequest', 'typical'); 298 | 299 | expect(example).toMatchObject({ 300 | method: 'POST', 301 | sendBody: true 302 | }); 303 | }); 304 | 305 | it('should return advanced example for complex task', () => { 306 | const example = ExampleGenerator.getTaskExample('nodes-base.httpRequest', 'complex'); 307 | 308 | expect(example).toMatchObject({ 309 | retryOnFail: true, 310 | maxTries: 3 311 | }); 312 | }); 313 | 314 | it('should default to common example for unknown task', () => { 315 | const example = ExampleGenerator.getTaskExample('nodes-base.httpRequest', 'unknown'); 316 | 317 | expect(example).toMatchObject({ 318 | method: 'POST' // This is from common example 319 | }); 320 | }); 321 | 322 | it('should return undefined for unknown node type', () => { 323 | const example = ExampleGenerator.getTaskExample('nodes-base.unknownNode', 'basic'); 324 | 325 | expect(example).toBeUndefined(); 326 | }); 327 | }); 328 | 329 | describe('default value generation', () => { 330 | it('should generate appropriate defaults for different property types', () => { 331 | const essentials = { 332 | required: [ 333 | { name: 'url', type: 'string' }, 334 | { name: 'port', type: 'number' }, 335 | { name: 'enabled', type: 'boolean' }, 336 | { name: 'method', type: 'options', options: [{ value: 'GET' }, { value: 'POST' }] }, 337 | { name: 'data', type: 'json' } 338 | ], 339 | common: [] 340 | }; 341 | 342 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 343 | 344 | expect(examples.minimal).toEqual({ 345 | url: 'https://api.example.com', 346 | port: 80, 347 | enabled: false, 348 | method: 'GET', 349 | data: '{\n "key": "value"\n}' 350 | }); 351 | }); 352 | 353 | it('should use property defaults when available', () => { 354 | const essentials = { 355 | required: [ 356 | { name: 'timeout', type: 'number', default: 5000 }, 357 | { name: 'retries', type: 'number', default: 3 } 358 | ], 359 | common: [] 360 | }; 361 | 362 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 363 | 364 | expect(examples.minimal).toEqual({ 365 | timeout: 5000, 366 | retries: 3 367 | }); 368 | }); 369 | 370 | it('should generate context-aware string defaults', () => { 371 | const essentials = { 372 | required: [ 373 | { name: 'fromEmail', type: 'string' }, 374 | { name: 'toEmail', type: 'string' }, 375 | { name: 'webhookPath', type: 'string' }, 376 | { name: 'username', type: 'string' }, 377 | { name: 'apiKey', type: 'string' }, 378 | { name: 'query', type: 'string' }, 379 | { name: 'collection', type: 'string' } 380 | ], 381 | common: [] 382 | }; 383 | 384 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 385 | 386 | expect(examples.minimal).toEqual({ 387 | fromEmail: '[email protected]', 388 | toEmail: '[email protected]', 389 | webhookPath: 'my-webhook', 390 | username: 'John Doe', 391 | apiKey: 'myKey', 392 | query: 'SELECT * FROM table_name LIMIT 10', 393 | collection: 'users' 394 | }); 395 | }); 396 | 397 | it('should use placeholder as fallback for string defaults', () => { 398 | const essentials = { 399 | required: [ 400 | { name: 'customField', type: 'string', placeholder: 'Enter custom value' } 401 | ], 402 | common: [] 403 | }; 404 | 405 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 406 | 407 | expect(examples.minimal).toEqual({ 408 | customField: 'Enter custom value' 409 | }); 410 | }); 411 | }); 412 | 413 | describe('edge cases', () => { 414 | it('should handle empty essentials object', () => { 415 | const essentials = { 416 | required: [], 417 | common: [] 418 | }; 419 | 420 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 421 | 422 | expect(examples.minimal).toEqual({}); 423 | }); 424 | 425 | it('should handle properties with missing options', () => { 426 | const essentials = { 427 | required: [ 428 | { name: 'choice', type: 'options' } // No options array 429 | ], 430 | common: [] 431 | }; 432 | 433 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 434 | 435 | expect(examples.minimal).toEqual({ 436 | choice: '' 437 | }); 438 | }); 439 | 440 | it('should handle collection and fixedCollection types', () => { 441 | const essentials = { 442 | required: [ 443 | { name: 'headers', type: 'collection' }, 444 | { name: 'options', type: 'fixedCollection' } 445 | ], 446 | common: [] 447 | }; 448 | 449 | const examples = ExampleGenerator.getExamples('nodes-base.unknownNode', essentials); 450 | 451 | expect(examples.minimal).toEqual({ 452 | headers: {}, 453 | options: {} 454 | }); 455 | }); 456 | }); 457 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/property-filter-edge-cases.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { PropertyFilter } from '@/services/property-filter'; 3 | import type { SimplifiedProperty } from '@/services/property-filter'; 4 | 5 | // Mock the database 6 | vi.mock('better-sqlite3'); 7 | 8 | describe('PropertyFilter - Edge Cases', () => { 9 | beforeEach(() => { 10 | vi.clearAllMocks(); 11 | }); 12 | 13 | describe('Null and Undefined Handling', () => { 14 | it('should handle null properties gracefully', () => { 15 | const result = PropertyFilter.getEssentials(null as any, 'nodes-base.http'); 16 | expect(result).toEqual({ required: [], common: [] }); 17 | }); 18 | 19 | it('should handle undefined properties gracefully', () => { 20 | const result = PropertyFilter.getEssentials(undefined as any, 'nodes-base.http'); 21 | expect(result).toEqual({ required: [], common: [] }); 22 | }); 23 | 24 | it('should handle null nodeType gracefully', () => { 25 | const properties = [{ name: 'test', type: 'string' }]; 26 | const result = PropertyFilter.getEssentials(properties, null as any); 27 | // Should fallback to inferEssentials 28 | expect(result.required).toBeDefined(); 29 | expect(result.common).toBeDefined(); 30 | }); 31 | 32 | it('should handle properties with null values', () => { 33 | const properties = [ 34 | { name: 'prop1', type: 'string', displayName: null, description: null }, 35 | null, 36 | undefined, 37 | { name: null, type: 'string' }, 38 | { name: 'prop2', type: null } 39 | ]; 40 | 41 | const result = PropertyFilter.getEssentials(properties as any, 'nodes-base.test'); 42 | expect(() => result).not.toThrow(); 43 | expect(result.required).toBeDefined(); 44 | expect(result.common).toBeDefined(); 45 | }); 46 | }); 47 | 48 | describe('Boundary Value Testing', () => { 49 | it('should handle empty properties array', () => { 50 | const result = PropertyFilter.getEssentials([], 'nodes-base.http'); 51 | expect(result).toEqual({ required: [], common: [] }); 52 | }); 53 | 54 | it('should handle very large properties array', () => { 55 | const largeProperties = Array(10000).fill(null).map((_, i) => ({ 56 | name: `prop${i}`, 57 | type: 'string', 58 | displayName: `Property ${i}`, 59 | description: `Description for property ${i}`, 60 | required: i % 100 === 0 61 | })); 62 | 63 | const start = Date.now(); 64 | const result = PropertyFilter.getEssentials(largeProperties, 'nodes-base.test'); 65 | const duration = Date.now() - start; 66 | 67 | expect(result).toBeDefined(); 68 | expect(duration).toBeLessThan(1000); // Should filter within 1 second 69 | // For unconfigured nodes, it uses inferEssentials which limits results 70 | expect(result.required.length + result.common.length).toBeLessThanOrEqual(30); 71 | }); 72 | 73 | it('should handle properties with extremely long strings', () => { 74 | const properties = [ 75 | { 76 | name: 'longProp', 77 | type: 'string', 78 | displayName: 'A'.repeat(1000), 79 | description: 'B'.repeat(10000), 80 | placeholder: 'C'.repeat(5000), 81 | required: true 82 | } 83 | ]; 84 | 85 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 86 | // For unconfigured nodes, this might be included as required 87 | const allProps = [...result.required, ...result.common]; 88 | const longProp = allProps.find(p => p.name === 'longProp'); 89 | if (longProp) { 90 | expect(longProp.displayName).toBeDefined(); 91 | } 92 | }); 93 | 94 | it('should limit options array size', () => { 95 | const manyOptions = Array(1000).fill(null).map((_, i) => ({ 96 | value: `option${i}`, 97 | name: `Option ${i}` 98 | })); 99 | 100 | const properties = [{ 101 | name: 'selectProp', 102 | type: 'options', 103 | displayName: 'Select Property', 104 | options: manyOptions, 105 | required: true 106 | }]; 107 | 108 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 109 | const allProps = [...result.required, ...result.common]; 110 | const selectProp = allProps.find(p => p.name === 'selectProp'); 111 | 112 | if (selectProp && selectProp.options) { 113 | // Should limit options to reasonable number 114 | expect(selectProp.options.length).toBeLessThanOrEqual(20); 115 | } 116 | }); 117 | }); 118 | 119 | describe('Property Type Handling', () => { 120 | it('should handle all n8n property types', () => { 121 | const propertyTypes = [ 122 | 'string', 'number', 'boolean', 'options', 'multiOptions', 123 | 'collection', 'fixedCollection', 'json', 'notice', 'assignmentCollection', 124 | 'resourceLocator', 'resourceMapper', 'filter', 'credentials' 125 | ]; 126 | 127 | const properties = propertyTypes.map(type => ({ 128 | name: `${type}Prop`, 129 | type, 130 | displayName: `${type} Property`, 131 | description: `A ${type} property` 132 | })); 133 | 134 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 135 | expect(result).toBeDefined(); 136 | 137 | const allProps = [...result.required, ...result.common]; 138 | // Should handle various types without crashing 139 | expect(allProps.length).toBeGreaterThan(0); 140 | }); 141 | 142 | it('should handle nested collection properties', () => { 143 | const properties = [{ 144 | name: 'collection', 145 | type: 'collection', 146 | displayName: 'Collection', 147 | options: [ 148 | { name: 'nested1', type: 'string', displayName: 'Nested 1' }, 149 | { name: 'nested2', type: 'number', displayName: 'Nested 2' } 150 | ] 151 | }]; 152 | 153 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 154 | const allProps = [...result.required, ...result.common]; 155 | 156 | // Should include the collection 157 | expect(allProps.some(p => p.name === 'collection')).toBe(true); 158 | }); 159 | 160 | it('should handle fixedCollection properties', () => { 161 | const properties = [{ 162 | name: 'headers', 163 | type: 'fixedCollection', 164 | displayName: 'Headers', 165 | typeOptions: { multipleValues: true }, 166 | options: [{ 167 | name: 'parameter', 168 | displayName: 'Parameter', 169 | values: [ 170 | { name: 'name', type: 'string', displayName: 'Name' }, 171 | { name: 'value', type: 'string', displayName: 'Value' } 172 | ] 173 | }] 174 | }]; 175 | 176 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 177 | const allProps = [...result.required, ...result.common]; 178 | 179 | // Should include the fixed collection 180 | expect(allProps.some(p => p.name === 'headers')).toBe(true); 181 | }); 182 | }); 183 | 184 | describe('Special Cases', () => { 185 | it('should handle circular references in properties', () => { 186 | const properties: any = [{ 187 | name: 'circular', 188 | type: 'string', 189 | displayName: 'Circular' 190 | }]; 191 | properties[0].self = properties[0]; 192 | 193 | expect(() => { 194 | PropertyFilter.getEssentials(properties, 'nodes-base.test'); 195 | }).not.toThrow(); 196 | }); 197 | 198 | it('should handle properties with special characters', () => { 199 | const properties = [ 200 | { name: 'prop-with-dash', type: 'string', displayName: 'Prop With Dash' }, 201 | { name: 'prop_with_underscore', type: 'string', displayName: 'Prop With Underscore' }, 202 | { name: 'prop.with.dot', type: 'string', displayName: 'Prop With Dot' }, 203 | { name: 'prop@special', type: 'string', displayName: 'Prop Special' } 204 | ]; 205 | 206 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 207 | expect(result).toBeDefined(); 208 | }); 209 | 210 | it('should handle duplicate property names', () => { 211 | const properties = [ 212 | { name: 'duplicate', type: 'string', displayName: 'First Duplicate' }, 213 | { name: 'duplicate', type: 'number', displayName: 'Second Duplicate' }, 214 | { name: 'duplicate', type: 'boolean', displayName: 'Third Duplicate' } 215 | ]; 216 | 217 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 218 | const allProps = [...result.required, ...result.common]; 219 | 220 | // Should deduplicate 221 | const duplicates = allProps.filter(p => p.name === 'duplicate'); 222 | expect(duplicates.length).toBe(1); 223 | }); 224 | }); 225 | 226 | describe('Node-Specific Configurations', () => { 227 | it('should apply HTTP Request specific filtering', () => { 228 | const properties = [ 229 | { name: 'url', type: 'string', required: true }, 230 | { name: 'method', type: 'options', options: [{ value: 'GET' }, { value: 'POST' }] }, 231 | { name: 'authentication', type: 'options' }, 232 | { name: 'sendBody', type: 'boolean' }, 233 | { name: 'contentType', type: 'options' }, 234 | { name: 'sendHeaders', type: 'fixedCollection' }, 235 | { name: 'someObscureOption', type: 'string' } 236 | ]; 237 | 238 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.httpRequest'); 239 | 240 | expect(result.required.some(p => p.name === 'url')).toBe(true); 241 | expect(result.common.some(p => p.name === 'method')).toBe(true); 242 | expect(result.common.some(p => p.name === 'authentication')).toBe(true); 243 | 244 | // Should not include obscure option 245 | const allProps = [...result.required, ...result.common]; 246 | expect(allProps.some(p => p.name === 'someObscureOption')).toBe(false); 247 | }); 248 | 249 | it('should apply Slack specific filtering', () => { 250 | const properties = [ 251 | { name: 'resource', type: 'options', required: true }, 252 | { name: 'operation', type: 'options', required: true }, 253 | { name: 'channel', type: 'string' }, 254 | { name: 'text', type: 'string' }, 255 | { name: 'attachments', type: 'collection' }, 256 | { name: 'ts', type: 'string' }, 257 | { name: 'advancedOption1', type: 'string' }, 258 | { name: 'advancedOption2', type: 'boolean' } 259 | ]; 260 | 261 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.slack'); 262 | 263 | // In the actual config, resource and operation are in common, not required 264 | expect(result.common.some(p => p.name === 'resource')).toBe(true); 265 | expect(result.common.some(p => p.name === 'operation')).toBe(true); 266 | expect(result.common.some(p => p.name === 'channel')).toBe(true); 267 | expect(result.common.some(p => p.name === 'text')).toBe(true); 268 | }); 269 | }); 270 | 271 | describe('Fallback Behavior', () => { 272 | it('should infer essentials for unconfigured nodes', () => { 273 | const properties = [ 274 | { name: 'requiredProp', type: 'string', required: true }, 275 | { name: 'commonProp', type: 'string', displayName: 'Common Property' }, 276 | { name: 'advancedProp', type: 'json', displayName: 'Advanced Property' }, 277 | { name: 'debugProp', type: 'boolean', displayName: 'Debug Mode' }, 278 | { name: 'internalProp', type: 'hidden' } 279 | ]; 280 | 281 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.unknownNode'); 282 | 283 | // Should include required properties 284 | expect(result.required.some(p => p.name === 'requiredProp')).toBe(true); 285 | 286 | // Should include some common properties 287 | expect(result.common.length).toBeGreaterThan(0); 288 | 289 | // Should not include internal/hidden properties 290 | const allProps = [...result.required, ...result.common]; 291 | expect(allProps.some(p => p.name === 'internalProp')).toBe(false); 292 | }); 293 | 294 | it('should handle nodes with only advanced properties', () => { 295 | const properties = [ 296 | { name: 'advanced1', type: 'json', displayName: 'Advanced Option 1' }, 297 | { name: 'advanced2', type: 'collection', displayName: 'Advanced Collection' }, 298 | { name: 'advanced3', type: 'assignmentCollection', displayName: 'Advanced Assignment' } 299 | ]; 300 | 301 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.advancedNode'); 302 | 303 | // Should still return some properties 304 | const allProps = [...result.required, ...result.common]; 305 | expect(allProps.length).toBeGreaterThan(0); 306 | }); 307 | }); 308 | 309 | describe('Property Simplification', () => { 310 | it('should simplify complex property structures', () => { 311 | const properties = [{ 312 | name: 'complexProp', 313 | type: 'options', 314 | displayName: 'Complex Property', 315 | description: 'A'.repeat(500), // Long description 316 | default: 'option1', 317 | placeholder: 'Select an option', 318 | hint: 'This is a hint', 319 | displayOptions: { show: { mode: ['advanced'] } }, 320 | options: Array(50).fill(null).map((_, i) => ({ 321 | value: `option${i}`, 322 | name: `Option ${i}`, 323 | description: `Description for option ${i}` 324 | })) 325 | }]; 326 | 327 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 328 | const allProps = [...result.required, ...result.common]; 329 | const simplified = allProps.find(p => p.name === 'complexProp'); 330 | 331 | if (simplified) { 332 | // Should include essential fields 333 | expect(simplified.name).toBe('complexProp'); 334 | expect(simplified.displayName).toBe('Complex Property'); 335 | expect(simplified.type).toBe('options'); 336 | 337 | // Should limit options 338 | if (simplified.options) { 339 | expect(simplified.options.length).toBeLessThanOrEqual(20); 340 | } 341 | } 342 | }); 343 | 344 | it('should handle properties without display names', () => { 345 | const properties = [ 346 | { name: 'prop_without_display', type: 'string', description: 'Property description' }, 347 | { name: 'anotherProp', displayName: '', type: 'number' } 348 | ]; 349 | 350 | const result = PropertyFilter.getEssentials(properties, 'nodes-base.test'); 351 | const allProps = [...result.required, ...result.common]; 352 | 353 | allProps.forEach(prop => { 354 | // Should have a displayName (fallback to name if needed) 355 | expect(prop.displayName).toBeTruthy(); 356 | expect(prop.displayName.length).toBeGreaterThan(0); 357 | }); 358 | }); 359 | }); 360 | 361 | describe('Performance', () => { 362 | it('should handle property filtering efficiently', () => { 363 | const nodeTypes = [ 364 | 'nodes-base.httpRequest', 365 | 'nodes-base.webhook', 366 | 'nodes-base.slack', 367 | 'nodes-base.googleSheets', 368 | 'nodes-base.postgres' 369 | ]; 370 | 371 | const properties = Array(100).fill(null).map((_, i) => ({ 372 | name: `prop${i}`, 373 | type: i % 2 === 0 ? 'string' : 'options', 374 | displayName: `Property ${i}`, 375 | required: i < 5 376 | })); 377 | 378 | const start = Date.now(); 379 | nodeTypes.forEach(nodeType => { 380 | PropertyFilter.getEssentials(properties, nodeType); 381 | }); 382 | const duration = Date.now() - start; 383 | 384 | // Should process multiple nodes quickly 385 | expect(duration).toBeLessThan(50); 386 | }); 387 | }); 388 | }); ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/system/diagnostic.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration Tests: handleDiagnostic 3 | * 4 | * Tests system diagnostic functionality. 5 | * Covers environment checks, API status, and verbose mode. 6 | */ 7 | 8 | import { describe, it, expect, beforeEach } from 'vitest'; 9 | import { createMcpContext } from '../utils/mcp-context'; 10 | import { InstanceContext } from '../../../../src/types/instance-context'; 11 | import { handleDiagnostic } from '../../../../src/mcp/handlers-n8n-manager'; 12 | import { DiagnosticResponse } from '../utils/response-types'; 13 | 14 | describe('Integration: handleDiagnostic', () => { 15 | let mcpContext: InstanceContext; 16 | 17 | beforeEach(() => { 18 | mcpContext = createMcpContext(); 19 | }); 20 | 21 | // ====================================================================== 22 | // Basic Diagnostic 23 | // ====================================================================== 24 | 25 | describe('Basic Diagnostic', () => { 26 | it('should run basic diagnostic check', async () => { 27 | const response = await handleDiagnostic( 28 | { params: { arguments: {} } }, 29 | mcpContext 30 | ); 31 | 32 | expect(response.success).toBe(true); 33 | expect(response.data).toBeDefined(); 34 | 35 | const data = response.data as DiagnosticResponse; 36 | 37 | // Verify core diagnostic fields 38 | expect(data).toHaveProperty('timestamp'); 39 | expect(data).toHaveProperty('environment'); 40 | expect(data).toHaveProperty('apiConfiguration'); 41 | expect(data).toHaveProperty('toolsAvailability'); 42 | expect(data).toHaveProperty('versionInfo'); 43 | expect(data).toHaveProperty('performance'); 44 | 45 | // Verify timestamp format 46 | expect(typeof data.timestamp).toBe('string'); 47 | const timestamp = new Date(data.timestamp); 48 | expect(timestamp.toString()).not.toBe('Invalid Date'); 49 | 50 | // Verify version info 51 | expect(data.versionInfo).toBeDefined(); 52 | if (data.versionInfo) { 53 | expect(data.versionInfo).toHaveProperty('current'); 54 | expect(data.versionInfo).toHaveProperty('upToDate'); 55 | expect(typeof data.versionInfo.upToDate).toBe('boolean'); 56 | } 57 | 58 | // Verify performance metrics 59 | expect(data.performance).toBeDefined(); 60 | if (data.performance) { 61 | expect(data.performance).toHaveProperty('diagnosticResponseTimeMs'); 62 | expect(typeof data.performance.diagnosticResponseTimeMs).toBe('number'); 63 | } 64 | }); 65 | 66 | it('should include environment variables', async () => { 67 | const response = await handleDiagnostic( 68 | { params: { arguments: {} } }, 69 | mcpContext 70 | ); 71 | 72 | const data = response.data as DiagnosticResponse; 73 | 74 | expect(data.environment).toBeDefined(); 75 | expect(data.environment).toHaveProperty('N8N_API_URL'); 76 | expect(data.environment).toHaveProperty('N8N_API_KEY'); 77 | expect(data.environment).toHaveProperty('NODE_ENV'); 78 | expect(data.environment).toHaveProperty('MCP_MODE'); 79 | expect(data.environment).toHaveProperty('isDocker'); 80 | expect(data.environment).toHaveProperty('cloudPlatform'); 81 | expect(data.environment).toHaveProperty('nodeVersion'); 82 | expect(data.environment).toHaveProperty('platform'); 83 | 84 | // API key should be masked 85 | if (data.environment.N8N_API_KEY) { 86 | expect(data.environment.N8N_API_KEY).toBe('***configured***'); 87 | } 88 | 89 | // Environment detection types 90 | expect(typeof data.environment.isDocker).toBe('boolean'); 91 | expect(typeof data.environment.nodeVersion).toBe('string'); 92 | expect(typeof data.environment.platform).toBe('string'); 93 | }); 94 | 95 | it('should check API configuration and connectivity', async () => { 96 | const response = await handleDiagnostic( 97 | { params: { arguments: {} } }, 98 | mcpContext 99 | ); 100 | 101 | const data = response.data as DiagnosticResponse; 102 | 103 | expect(data.apiConfiguration).toBeDefined(); 104 | expect(data.apiConfiguration).toHaveProperty('configured'); 105 | expect(data.apiConfiguration).toHaveProperty('status'); 106 | 107 | // In test environment, API should be configured 108 | expect(data.apiConfiguration.configured).toBe(true); 109 | 110 | // Verify API status 111 | const status = data.apiConfiguration.status; 112 | expect(status).toHaveProperty('configured'); 113 | expect(status).toHaveProperty('connected'); 114 | 115 | // Should successfully connect to n8n API 116 | expect(status.connected).toBe(true); 117 | 118 | // If connected, should have version info 119 | if (status.connected) { 120 | expect(status).toHaveProperty('version'); 121 | } 122 | 123 | // Config details should be present when configured 124 | if (data.apiConfiguration.configured) { 125 | expect(data.apiConfiguration).toHaveProperty('config'); 126 | expect(data.apiConfiguration.config).toHaveProperty('baseUrl'); 127 | expect(data.apiConfiguration.config).toHaveProperty('timeout'); 128 | expect(data.apiConfiguration.config).toHaveProperty('maxRetries'); 129 | } 130 | }); 131 | 132 | it('should report tools availability', async () => { 133 | const response = await handleDiagnostic( 134 | { params: { arguments: {} } }, 135 | mcpContext 136 | ); 137 | 138 | const data = response.data as DiagnosticResponse; 139 | 140 | expect(data.toolsAvailability).toBeDefined(); 141 | expect(data.toolsAvailability).toHaveProperty('documentationTools'); 142 | expect(data.toolsAvailability).toHaveProperty('managementTools'); 143 | expect(data.toolsAvailability).toHaveProperty('totalAvailable'); 144 | 145 | // Documentation tools should always be available 146 | const docTools = data.toolsAvailability.documentationTools; 147 | expect(docTools.count).toBeGreaterThan(0); 148 | expect(docTools.enabled).toBe(true); 149 | expect(docTools.description).toBeDefined(); 150 | 151 | // Management tools should be available when API configured 152 | const mgmtTools = data.toolsAvailability.managementTools; 153 | expect(mgmtTools).toHaveProperty('count'); 154 | expect(mgmtTools).toHaveProperty('enabled'); 155 | expect(mgmtTools).toHaveProperty('description'); 156 | 157 | // In test environment, management tools should be enabled 158 | expect(mgmtTools.enabled).toBe(true); 159 | expect(mgmtTools.count).toBeGreaterThan(0); 160 | 161 | // Total should be sum of both 162 | expect(data.toolsAvailability.totalAvailable).toBe( 163 | docTools.count + mgmtTools.count 164 | ); 165 | }); 166 | 167 | it('should include troubleshooting information', async () => { 168 | const response = await handleDiagnostic( 169 | { params: { arguments: {} } }, 170 | mcpContext 171 | ); 172 | 173 | const data = response.data as DiagnosticResponse; 174 | 175 | // Should have either nextSteps (if API connected) or setupGuide (if not configured) 176 | const hasGuidance = data.nextSteps || data.setupGuide || data.troubleshooting; 177 | expect(hasGuidance).toBeDefined(); 178 | 179 | if (data.nextSteps) { 180 | expect(data.nextSteps).toHaveProperty('message'); 181 | expect(data.nextSteps).toHaveProperty('recommended'); 182 | expect(Array.isArray(data.nextSteps.recommended)).toBe(true); 183 | } 184 | 185 | if (data.setupGuide) { 186 | expect(data.setupGuide).toHaveProperty('message'); 187 | expect(data.setupGuide).toHaveProperty('whatYouCanDoNow'); 188 | expect(data.setupGuide).toHaveProperty('whatYouCannotDo'); 189 | expect(data.setupGuide).toHaveProperty('howToEnable'); 190 | } 191 | 192 | if (data.troubleshooting) { 193 | expect(data.troubleshooting).toHaveProperty('issue'); 194 | expect(data.troubleshooting).toHaveProperty('steps'); 195 | expect(Array.isArray(data.troubleshooting.steps)).toBe(true); 196 | } 197 | }); 198 | }); 199 | 200 | // ====================================================================== 201 | // Environment Detection 202 | // ====================================================================== 203 | 204 | describe('Environment Detection', () => { 205 | it('should provide mode-specific debugging suggestions', async () => { 206 | const response = await handleDiagnostic( 207 | { params: { arguments: {} } }, 208 | mcpContext 209 | ); 210 | 211 | const data = response.data as DiagnosticResponse; 212 | 213 | // Mode-specific debug should always be present 214 | expect(data).toHaveProperty('modeSpecificDebug'); 215 | expect(data.modeSpecificDebug).toBeDefined(); 216 | expect(data.modeSpecificDebug).toHaveProperty('mode'); 217 | expect(data.modeSpecificDebug).toHaveProperty('troubleshooting'); 218 | expect(data.modeSpecificDebug).toHaveProperty('commonIssues'); 219 | 220 | // Verify troubleshooting is an array with content 221 | expect(Array.isArray(data.modeSpecificDebug.troubleshooting)).toBe(true); 222 | expect(data.modeSpecificDebug.troubleshooting.length).toBeGreaterThan(0); 223 | 224 | // Verify common issues is an array with content 225 | expect(Array.isArray(data.modeSpecificDebug.commonIssues)).toBe(true); 226 | expect(data.modeSpecificDebug.commonIssues.length).toBeGreaterThan(0); 227 | 228 | // Mode should be either 'HTTP Server' or 'Standard I/O (Claude Desktop)' 229 | expect(['HTTP Server', 'Standard I/O (Claude Desktop)']).toContain(data.modeSpecificDebug.mode); 230 | }); 231 | 232 | it('should include Docker debugging if IS_DOCKER is true', async () => { 233 | // Save original value 234 | const originalIsDocker = process.env.IS_DOCKER; 235 | 236 | try { 237 | // Set IS_DOCKER for this test 238 | process.env.IS_DOCKER = 'true'; 239 | 240 | const response = await handleDiagnostic( 241 | { params: { arguments: {} } }, 242 | mcpContext 243 | ); 244 | 245 | const data = response.data as DiagnosticResponse; 246 | 247 | // Should have Docker debug section 248 | expect(data).toHaveProperty('dockerDebug'); 249 | expect(data.dockerDebug).toBeDefined(); 250 | expect(data.dockerDebug?.containerDetected).toBe(true); 251 | expect(data.dockerDebug?.troubleshooting).toBeDefined(); 252 | expect(Array.isArray(data.dockerDebug?.troubleshooting)).toBe(true); 253 | expect(data.dockerDebug?.commonIssues).toBeDefined(); 254 | } finally { 255 | // Restore original value 256 | if (originalIsDocker) { 257 | process.env.IS_DOCKER = originalIsDocker; 258 | } else { 259 | delete process.env.IS_DOCKER; 260 | } 261 | } 262 | }); 263 | 264 | it('should not include Docker debugging if IS_DOCKER is false', async () => { 265 | // Save original value 266 | const originalIsDocker = process.env.IS_DOCKER; 267 | 268 | try { 269 | // Unset IS_DOCKER for this test 270 | delete process.env.IS_DOCKER; 271 | 272 | const response = await handleDiagnostic( 273 | { params: { arguments: {} } }, 274 | mcpContext 275 | ); 276 | 277 | const data = response.data as DiagnosticResponse; 278 | 279 | // Should not have Docker debug section 280 | expect(data.dockerDebug).toBeUndefined(); 281 | } finally { 282 | // Restore original value 283 | if (originalIsDocker) { 284 | process.env.IS_DOCKER = originalIsDocker; 285 | } 286 | } 287 | }); 288 | }); 289 | 290 | // ====================================================================== 291 | // Verbose Mode 292 | // ====================================================================== 293 | 294 | describe('Verbose Mode', () => { 295 | it('should include additional debug info in verbose mode', async () => { 296 | const response = await handleDiagnostic( 297 | { params: { arguments: { verbose: true } } }, 298 | mcpContext 299 | ); 300 | 301 | expect(response.success).toBe(true); 302 | const data = response.data as DiagnosticResponse; 303 | 304 | // Verbose mode should add debug section 305 | expect(data).toHaveProperty('debug'); 306 | expect(data.debug).toBeDefined(); 307 | 308 | // Verify debug information 309 | expect(data.debug).toBeDefined(); 310 | expect(data.debug).toHaveProperty('processEnv'); 311 | expect(data.debug).toHaveProperty('nodeVersion'); 312 | expect(data.debug).toHaveProperty('platform'); 313 | expect(data.debug).toHaveProperty('workingDirectory'); 314 | 315 | // Process env should list relevant environment variables 316 | expect(Array.isArray(data.debug?.processEnv)).toBe(true); 317 | 318 | // Node version should be a string 319 | expect(typeof data.debug?.nodeVersion).toBe('string'); 320 | expect(data.debug?.nodeVersion).toMatch(/^v\d+\.\d+\.\d+/); 321 | 322 | // Platform should be a string (linux, darwin, win32, etc.) 323 | expect(typeof data.debug?.platform).toBe('string'); 324 | expect(data.debug && data.debug.platform.length).toBeGreaterThan(0); 325 | 326 | // Working directory should be a path 327 | expect(typeof data.debug?.workingDirectory).toBe('string'); 328 | expect(data.debug && data.debug.workingDirectory.length).toBeGreaterThan(0); 329 | }); 330 | 331 | it('should not include debug info when verbose is false', async () => { 332 | const response = await handleDiagnostic( 333 | { params: { arguments: { verbose: false } } }, 334 | mcpContext 335 | ); 336 | 337 | expect(response.success).toBe(true); 338 | const data = response.data as DiagnosticResponse; 339 | 340 | // Debug section should not be present 341 | expect(data.debug).toBeUndefined(); 342 | }); 343 | 344 | it('should not include debug info by default', async () => { 345 | const response = await handleDiagnostic( 346 | { params: { arguments: {} } }, 347 | mcpContext 348 | ); 349 | 350 | expect(response.success).toBe(true); 351 | const data = response.data as DiagnosticResponse; 352 | 353 | // Debug section should not be present when verbose not specified 354 | expect(data.debug).toBeUndefined(); 355 | }); 356 | }); 357 | 358 | // ====================================================================== 359 | // Response Format Verification 360 | // ====================================================================== 361 | 362 | describe('Response Format', () => { 363 | it('should return complete diagnostic response structure', async () => { 364 | const response = await handleDiagnostic( 365 | { params: { arguments: {} } }, 366 | mcpContext 367 | ); 368 | 369 | expect(response.success).toBe(true); 370 | expect(response.data).toBeDefined(); 371 | 372 | const data = response.data as DiagnosticResponse; 373 | 374 | // Verify all required fields (always present) 375 | const requiredFields = [ 376 | 'timestamp', 377 | 'environment', 378 | 'apiConfiguration', 379 | 'toolsAvailability', 380 | 'versionInfo', 381 | 'performance' 382 | ]; 383 | 384 | requiredFields.forEach(field => { 385 | expect(data).toHaveProperty(field); 386 | expect(data[field]).toBeDefined(); 387 | }); 388 | 389 | // Context-specific fields (at least one should be present) 390 | const hasContextualGuidance = data.nextSteps || data.setupGuide || data.troubleshooting; 391 | expect(hasContextualGuidance).toBeDefined(); 392 | 393 | // Verify data types 394 | expect(typeof data.timestamp).toBe('string'); 395 | expect(typeof data.environment).toBe('object'); 396 | expect(typeof data.apiConfiguration).toBe('object'); 397 | expect(typeof data.toolsAvailability).toBe('object'); 398 | expect(typeof data.versionInfo).toBe('object'); 399 | expect(typeof data.performance).toBe('object'); 400 | }); 401 | }); 402 | }); 403 | ``` -------------------------------------------------------------------------------- /tests/integration/mcp/template-examples-e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { createDatabaseAdapter, DatabaseAdapter } from '../../../src/database/database-adapter'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { sampleConfigs, compressWorkflow, sampleWorkflows } from '../../fixtures/template-configs'; 6 | 7 | /** 8 | * End-to-end integration tests for template-based examples feature 9 | * Tests the complete flow: database -> MCP server -> examples in response 10 | */ 11 | 12 | describe('Template Examples E2E Integration', () => { 13 | let db: DatabaseAdapter; 14 | 15 | beforeEach(async () => { 16 | // Create in-memory database 17 | db = await createDatabaseAdapter(':memory:'); 18 | 19 | // Apply schema 20 | const schemaPath = path.join(__dirname, '../../../src/database/schema.sql'); 21 | const schema = fs.readFileSync(schemaPath, 'utf-8'); 22 | db.exec(schema); 23 | 24 | // Apply migration 25 | const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql'); 26 | const migration = fs.readFileSync(migrationPath, 'utf-8'); 27 | db.exec(migration); 28 | 29 | // Seed test data 30 | seedTemplateConfigs(); 31 | }); 32 | 33 | afterEach(() => { 34 | if ('close' in db && typeof db.close === 'function') { 35 | db.close(); 36 | } 37 | }); 38 | 39 | function seedTemplateConfigs() { 40 | // Insert sample templates first to satisfy foreign key constraints 41 | // The sampleConfigs use template_id 1-4, edge cases use 998-999 42 | const templateIds = [1, 2, 3, 4, 998, 999]; 43 | for (const id of templateIds) { 44 | db.prepare(` 45 | INSERT INTO templates ( 46 | id, workflow_id, name, description, views, 47 | nodes_used, created_at, updated_at 48 | ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) 49 | `).run( 50 | id, 51 | id, 52 | `Test Template ${id}`, 53 | 'Test Description', 54 | 1000, 55 | JSON.stringify(['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest']) 56 | ); 57 | } 58 | 59 | // Insert webhook configs 60 | db.prepare(` 61 | INSERT INTO template_node_configs ( 62 | node_type, template_id, template_name, template_views, 63 | node_name, parameters_json, credentials_json, 64 | has_credentials, has_expressions, complexity, use_cases, rank 65 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 66 | `).run( 67 | ...Object.values(sampleConfigs.simpleWebhook) 68 | ); 69 | 70 | db.prepare(` 71 | INSERT INTO template_node_configs ( 72 | node_type, template_id, template_name, template_views, 73 | node_name, parameters_json, credentials_json, 74 | has_credentials, has_expressions, complexity, use_cases, rank 75 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 76 | `).run( 77 | ...Object.values(sampleConfigs.webhookWithAuth) 78 | ); 79 | 80 | // Insert HTTP request configs 81 | db.prepare(` 82 | INSERT INTO template_node_configs ( 83 | node_type, template_id, template_name, template_views, 84 | node_name, parameters_json, credentials_json, 85 | has_credentials, has_expressions, complexity, use_cases, rank 86 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 87 | `).run( 88 | ...Object.values(sampleConfigs.httpRequestBasic) 89 | ); 90 | 91 | db.prepare(` 92 | INSERT INTO template_node_configs ( 93 | node_type, template_id, template_name, template_views, 94 | node_name, parameters_json, credentials_json, 95 | has_credentials, has_expressions, complexity, use_cases, rank 96 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 97 | `).run( 98 | ...Object.values(sampleConfigs.httpRequestWithExpressions) 99 | ); 100 | } 101 | 102 | describe('Querying Examples Directly', () => { 103 | it('should fetch top 2 examples for webhook node', () => { 104 | const examples = db.prepare(` 105 | SELECT 106 | parameters_json, 107 | template_name, 108 | template_views 109 | FROM template_node_configs 110 | WHERE node_type = ? 111 | ORDER BY rank 112 | LIMIT 2 113 | `).all('n8n-nodes-base.webhook') as any[]; 114 | 115 | expect(examples).toHaveLength(2); 116 | expect(examples[0].template_name).toBe('Simple Webhook Trigger'); 117 | expect(examples[1].template_name).toBe('Authenticated Webhook'); 118 | }); 119 | 120 | it('should fetch top 3 examples with metadata for HTTP request node', () => { 121 | const examples = db.prepare(` 122 | SELECT 123 | parameters_json, 124 | template_name, 125 | template_views, 126 | complexity, 127 | use_cases, 128 | has_credentials, 129 | has_expressions 130 | FROM template_node_configs 131 | WHERE node_type = ? 132 | ORDER BY rank 133 | LIMIT 3 134 | `).all('n8n-nodes-base.httpRequest') as any[]; 135 | 136 | expect(examples).toHaveLength(2); // Only 2 inserted 137 | expect(examples[0].template_name).toBe('Basic HTTP GET Request'); 138 | expect(examples[0].complexity).toBe('simple'); 139 | expect(examples[0].has_expressions).toBe(0); 140 | 141 | expect(examples[1].template_name).toBe('Dynamic HTTP Request'); 142 | expect(examples[1].complexity).toBe('complex'); 143 | expect(examples[1].has_expressions).toBe(1); 144 | }); 145 | }); 146 | 147 | describe('Example Data Structure Validation', () => { 148 | it('should have valid JSON in parameters_json', () => { 149 | const examples = db.prepare(` 150 | SELECT parameters_json 151 | FROM template_node_configs 152 | WHERE node_type = ? 153 | LIMIT 1 154 | `).all('n8n-nodes-base.webhook') as any[]; 155 | 156 | expect(() => { 157 | const params = JSON.parse(examples[0].parameters_json); 158 | expect(params).toHaveProperty('httpMethod'); 159 | expect(params).toHaveProperty('path'); 160 | }).not.toThrow(); 161 | }); 162 | 163 | it('should have valid JSON in use_cases', () => { 164 | const examples = db.prepare(` 165 | SELECT use_cases 166 | FROM template_node_configs 167 | WHERE node_type = ? 168 | LIMIT 1 169 | `).all('n8n-nodes-base.webhook') as any[]; 170 | 171 | expect(() => { 172 | const useCases = JSON.parse(examples[0].use_cases); 173 | expect(Array.isArray(useCases)).toBe(true); 174 | }).not.toThrow(); 175 | }); 176 | 177 | it('should have credentials_json when has_credentials is 1', () => { 178 | const examples = db.prepare(` 179 | SELECT credentials_json, has_credentials 180 | FROM template_node_configs 181 | WHERE has_credentials = 1 182 | LIMIT 1 183 | `).all() as any[]; 184 | 185 | if (examples.length > 0) { 186 | expect(examples[0].credentials_json).not.toBeNull(); 187 | expect(() => { 188 | JSON.parse(examples[0].credentials_json); 189 | }).not.toThrow(); 190 | } 191 | }); 192 | }); 193 | 194 | describe('Ranked View Functionality', () => { 195 | it('should return only top 5 ranked configs per node type from view', () => { 196 | // Insert templates first to satisfy foreign key constraints 197 | // Note: seedTemplateConfigs already created templates 1-4, so start from 5 198 | for (let i = 5; i <= 14; i++) { 199 | db.prepare(` 200 | INSERT INTO templates ( 201 | id, workflow_id, name, description, views, 202 | nodes_used, created_at, updated_at 203 | ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) 204 | `).run(i, i, `Template ${i}`, 'Test', 1000 - (i * 50), '[]'); 205 | } 206 | 207 | // Insert 10 configs for same node type 208 | for (let i = 5; i <= 14; i++) { 209 | db.prepare(` 210 | INSERT INTO template_node_configs ( 211 | node_type, template_id, template_name, template_views, 212 | node_name, parameters_json, rank 213 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 214 | `).run( 215 | 'n8n-nodes-base.webhook', 216 | i, 217 | `Template ${i}`, 218 | 1000 - (i * 50), 219 | 'Webhook', 220 | '{}', 221 | i 222 | ); 223 | } 224 | 225 | const rankedConfigs = db.prepare(` 226 | SELECT * FROM ranked_node_configs 227 | WHERE node_type = ? 228 | `).all('n8n-nodes-base.webhook') as any[]; 229 | 230 | expect(rankedConfigs.length).toBeLessThanOrEqual(5); 231 | }); 232 | }); 233 | 234 | describe('Performance with Real-World Data Volume', () => { 235 | beforeEach(() => { 236 | // Insert templates first to satisfy foreign key constraints 237 | for (let i = 1; i <= 100; i++) { 238 | db.prepare(` 239 | INSERT INTO templates ( 240 | id, workflow_id, name, description, views, 241 | nodes_used, created_at, updated_at 242 | ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) 243 | `).run(i + 100, i + 100, `Template ${i}`, 'Test', Math.floor(Math.random() * 10000), '[]'); 244 | } 245 | 246 | // Insert 100 configs across 10 different node types 247 | const nodeTypes = [ 248 | 'n8n-nodes-base.slack', 249 | 'n8n-nodes-base.googleSheets', 250 | 'n8n-nodes-base.code', 251 | 'n8n-nodes-base.if', 252 | 'n8n-nodes-base.switch', 253 | 'n8n-nodes-base.set', 254 | 'n8n-nodes-base.merge', 255 | 'n8n-nodes-base.splitInBatches', 256 | 'n8n-nodes-base.postgres', 257 | 'n8n-nodes-base.gmail' 258 | ]; 259 | 260 | for (let i = 1; i <= 100; i++) { 261 | const nodeType = nodeTypes[i % nodeTypes.length]; 262 | db.prepare(` 263 | INSERT INTO template_node_configs ( 264 | node_type, template_id, template_name, template_views, 265 | node_name, parameters_json, rank 266 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 267 | `).run( 268 | nodeType, 269 | i + 100, // Offset template_id 270 | `Template ${i}`, 271 | Math.floor(Math.random() * 10000), 272 | 'Node', 273 | '{}', 274 | (i % 10) + 1 275 | ); 276 | } 277 | }); 278 | 279 | it('should query specific node type examples quickly', () => { 280 | const start = Date.now(); 281 | const examples = db.prepare(` 282 | SELECT * FROM template_node_configs 283 | WHERE node_type = ? 284 | ORDER BY rank 285 | LIMIT 3 286 | `).all('n8n-nodes-base.slack') as any[]; 287 | const duration = Date.now() - start; 288 | 289 | expect(examples.length).toBeGreaterThan(0); 290 | expect(duration).toBeLessThan(5); // Should be very fast with index 291 | }); 292 | 293 | it('should filter by complexity efficiently', () => { 294 | // Set complexity on configs 295 | db.exec(`UPDATE template_node_configs SET complexity = 'simple' WHERE id % 3 = 0`); 296 | db.exec(`UPDATE template_node_configs SET complexity = 'medium' WHERE id % 3 = 1`); 297 | 298 | const start = Date.now(); 299 | const examples = db.prepare(` 300 | SELECT * FROM template_node_configs 301 | WHERE node_type = ? AND complexity = ? 302 | ORDER BY rank 303 | LIMIT 3 304 | `).all('n8n-nodes-base.code', 'simple') as any[]; 305 | const duration = Date.now() - start; 306 | 307 | expect(duration).toBeLessThan(5); 308 | }); 309 | }); 310 | 311 | describe('Edge Cases', () => { 312 | it('should handle node types with no configs', () => { 313 | const examples = db.prepare(` 314 | SELECT * FROM template_node_configs 315 | WHERE node_type = ? 316 | LIMIT 2 317 | `).all('n8n-nodes-base.nonexistent') as any[]; 318 | 319 | expect(examples).toHaveLength(0); 320 | }); 321 | 322 | it('should handle very long parameters_json', () => { 323 | const longParams = JSON.stringify({ 324 | options: { 325 | queryParameters: Array.from({ length: 100 }, (_, i) => ({ 326 | name: `param${i}`, 327 | value: `value${i}`.repeat(10) 328 | })) 329 | } 330 | }); 331 | 332 | db.prepare(` 333 | INSERT INTO template_node_configs ( 334 | node_type, template_id, template_name, template_views, 335 | node_name, parameters_json, rank 336 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 337 | `).run( 338 | 'n8n-nodes-base.test', 339 | 999, 340 | 'Long Params Template', 341 | 100, 342 | 'Test', 343 | longParams, 344 | 1 345 | ); 346 | 347 | const example = db.prepare(` 348 | SELECT parameters_json FROM template_node_configs WHERE template_id = ? 349 | `).get(999) as any; 350 | 351 | expect(() => { 352 | const parsed = JSON.parse(example.parameters_json); 353 | expect(parsed.options.queryParameters).toHaveLength(100); 354 | }).not.toThrow(); 355 | }); 356 | 357 | it('should handle special characters in parameters', () => { 358 | const specialParams = JSON.stringify({ 359 | message: "Test with 'quotes' and \"double quotes\"", 360 | unicode: "特殊文字 🎉 émojis", 361 | symbols: "!@#$%^&*()_+-={}[]|\\:;<>?,./" 362 | }); 363 | 364 | db.prepare(` 365 | INSERT INTO template_node_configs ( 366 | node_type, template_id, template_name, template_views, 367 | node_name, parameters_json, rank 368 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 369 | `).run( 370 | 'n8n-nodes-base.test', 371 | 998, 372 | 'Special Chars Template', 373 | 100, 374 | 'Test', 375 | specialParams, 376 | 1 377 | ); 378 | 379 | const example = db.prepare(` 380 | SELECT parameters_json FROM template_node_configs WHERE template_id = ? 381 | `).get(998) as any; 382 | 383 | expect(() => { 384 | const parsed = JSON.parse(example.parameters_json); 385 | expect(parsed.message).toContain("'quotes'"); 386 | expect(parsed.unicode).toContain("🎉"); 387 | }).not.toThrow(); 388 | }); 389 | }); 390 | 391 | describe('Data Integrity', () => { 392 | it('should maintain referential integrity with templates table', () => { 393 | // Try to insert config with non-existent template_id (with FK enabled) 394 | db.exec('PRAGMA foreign_keys = ON'); 395 | 396 | expect(() => { 397 | db.prepare(` 398 | INSERT INTO template_node_configs ( 399 | node_type, template_id, template_name, template_views, 400 | node_name, parameters_json, rank 401 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 402 | `).run( 403 | 'n8n-nodes-base.test', 404 | 999999, // Non-existent template_id 405 | 'Test', 406 | 100, 407 | 'Node', 408 | '{}', 409 | 1 410 | ); 411 | }).toThrow(); // Should fail due to FK constraint 412 | }); 413 | 414 | it('should cascade delete configs when template is deleted', () => { 415 | db.exec('PRAGMA foreign_keys = ON'); 416 | 417 | // Insert a new template (use id 1000 to avoid conflicts with seedTemplateConfigs) 418 | db.prepare(` 419 | INSERT INTO templates ( 420 | id, workflow_id, name, description, views, 421 | nodes_used, created_at, updated_at 422 | ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) 423 | `).run(1000, 1000, 'Test Template 1000', 'Desc', 100, '[]'); 424 | 425 | db.prepare(` 426 | INSERT INTO template_node_configs ( 427 | node_type, template_id, template_name, template_views, 428 | node_name, parameters_json, rank 429 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 430 | `).run( 431 | 'n8n-nodes-base.test', 432 | 1000, 433 | 'Test', 434 | 100, 435 | 'Node', 436 | '{}', 437 | 1 438 | ); 439 | 440 | // Verify config exists 441 | let config = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').get(1000); 442 | expect(config).toBeDefined(); 443 | 444 | // Delete template 445 | db.prepare('DELETE FROM templates WHERE id = ?').run(1000); 446 | 447 | // Verify config is deleted (CASCADE) 448 | config = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').get(1000); 449 | expect(config).toBeUndefined(); 450 | }); 451 | }); 452 | }); 453 | ``` -------------------------------------------------------------------------------- /src/templates/batch-processor.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import OpenAI from 'openai'; 4 | import { logger } from '../utils/logger'; 5 | import { MetadataGenerator, MetadataRequest, MetadataResult } from './metadata-generator'; 6 | 7 | export interface BatchProcessorOptions { 8 | apiKey: string; 9 | model?: string; 10 | batchSize?: number; 11 | outputDir?: string; 12 | } 13 | 14 | export interface BatchJob { 15 | id: string; 16 | status: 'validating' | 'in_progress' | 'finalizing' | 'completed' | 'failed' | 'expired' | 'cancelled'; 17 | created_at: number; 18 | completed_at?: number; 19 | input_file_id: string; 20 | output_file_id?: string; 21 | error?: any; 22 | } 23 | 24 | export class BatchProcessor { 25 | private client: OpenAI; 26 | private generator: MetadataGenerator; 27 | private batchSize: number; 28 | private outputDir: string; 29 | 30 | constructor(options: BatchProcessorOptions) { 31 | this.client = new OpenAI({ apiKey: options.apiKey }); 32 | this.generator = new MetadataGenerator(options.apiKey, options.model); 33 | this.batchSize = options.batchSize || 100; 34 | this.outputDir = options.outputDir || './temp'; 35 | 36 | // Ensure output directory exists 37 | if (!fs.existsSync(this.outputDir)) { 38 | fs.mkdirSync(this.outputDir, { recursive: true }); 39 | } 40 | } 41 | 42 | /** 43 | * Process templates in batches (parallel submission) 44 | */ 45 | async processTemplates( 46 | templates: MetadataRequest[], 47 | progressCallback?: (message: string, current: number, total: number) => void 48 | ): Promise<Map<number, MetadataResult>> { 49 | const results = new Map<number, MetadataResult>(); 50 | const batches = this.createBatches(templates); 51 | 52 | logger.info(`Processing ${templates.length} templates in ${batches.length} batches`); 53 | 54 | // Submit all batches in parallel 55 | console.log(`\n📤 Submitting ${batches.length} batch${batches.length > 1 ? 'es' : ''} to OpenAI...`); 56 | const batchJobs: Array<{ batchNum: number; jobPromise: Promise<any>; templates: MetadataRequest[] }> = []; 57 | 58 | for (let i = 0; i < batches.length; i++) { 59 | const batch = batches[i]; 60 | const batchNum = i + 1; 61 | 62 | try { 63 | progressCallback?.(`Submitting batch ${batchNum}/${batches.length}`, i * this.batchSize, templates.length); 64 | 65 | // Submit batch (don't wait for completion) 66 | const jobPromise = this.submitBatch(batch, `batch_${batchNum}`); 67 | batchJobs.push({ batchNum, jobPromise, templates: batch }); 68 | 69 | console.log(` 📨 Submitted batch ${batchNum}/${batches.length} (${batch.length} templates)`); 70 | } catch (error) { 71 | logger.error(`Error submitting batch ${batchNum}:`, error); 72 | console.error(` ❌ Failed to submit batch ${batchNum}`); 73 | } 74 | } 75 | 76 | console.log(`\n⏳ All batches submitted. Waiting for completion...`); 77 | console.log(` (Batches process in parallel - this is much faster than sequential processing)`); 78 | 79 | // Process all batches in parallel and collect results as they complete 80 | const batchPromises = batchJobs.map(async ({ batchNum, jobPromise, templates: batchTemplates }) => { 81 | try { 82 | const completedJob = await jobPromise; 83 | console.log(`\n📦 Retrieving results for batch ${batchNum}/${batches.length}...`); 84 | 85 | // Retrieve and parse results 86 | const batchResults = await this.retrieveResults(completedJob); 87 | 88 | logger.info(`Retrieved ${batchResults.length} results from batch ${batchNum}`); 89 | progressCallback?.(`Retrieved batch ${batchNum}/${batches.length}`, 90 | Math.min(batchNum * this.batchSize, templates.length), templates.length); 91 | 92 | return { batchNum, results: batchResults }; 93 | } catch (error) { 94 | logger.error(`Error processing batch ${batchNum}:`, error); 95 | console.error(` ❌ Batch ${batchNum} failed:`, error); 96 | return { batchNum, results: [] }; 97 | } 98 | }); 99 | 100 | // Wait for all batches to complete 101 | const allBatchResults = await Promise.all(batchPromises); 102 | 103 | // Merge all results 104 | for (const { batchNum, results: batchResults } of allBatchResults) { 105 | for (const result of batchResults) { 106 | results.set(result.templateId, result); 107 | } 108 | if (batchResults.length > 0) { 109 | console.log(` ✅ Merged ${batchResults.length} results from batch ${batchNum}`); 110 | } 111 | } 112 | 113 | logger.info(`Batch processing complete: ${results.size} results`); 114 | return results; 115 | } 116 | 117 | /** 118 | * Submit a batch without waiting for completion 119 | */ 120 | private async submitBatch(templates: MetadataRequest[], batchName: string): Promise<any> { 121 | // Create JSONL file 122 | const inputFile = await this.createBatchFile(templates, batchName); 123 | 124 | try { 125 | // Upload file to OpenAI 126 | const uploadedFile = await this.uploadFile(inputFile); 127 | 128 | // Create batch job 129 | const batchJob = await this.createBatchJob(uploadedFile.id); 130 | 131 | // Start monitoring (returns promise that resolves when complete) 132 | const monitoringPromise = this.monitorBatchJob(batchJob.id); 133 | 134 | // Clean up input file immediately 135 | try { 136 | fs.unlinkSync(inputFile); 137 | } catch {} 138 | 139 | // Store file IDs for cleanup later 140 | monitoringPromise.then(async (completedJob) => { 141 | // Cleanup uploaded files after completion 142 | try { 143 | await this.client.files.del(uploadedFile.id); 144 | if (completedJob.output_file_id) { 145 | // Note: We'll delete output file after retrieving results 146 | } 147 | } catch (error) { 148 | logger.warn(`Failed to cleanup files for batch ${batchName}`, error); 149 | } 150 | }); 151 | 152 | return monitoringPromise; 153 | } catch (error) { 154 | // Cleanup on error 155 | try { 156 | fs.unlinkSync(inputFile); 157 | } catch {} 158 | throw error; 159 | } 160 | } 161 | 162 | /** 163 | * Process a single batch 164 | */ 165 | private async processBatch(templates: MetadataRequest[], batchName: string): Promise<MetadataResult[]> { 166 | // Create JSONL file 167 | const inputFile = await this.createBatchFile(templates, batchName); 168 | 169 | try { 170 | // Upload file to OpenAI 171 | const uploadedFile = await this.uploadFile(inputFile); 172 | 173 | // Create batch job 174 | const batchJob = await this.createBatchJob(uploadedFile.id); 175 | 176 | // Monitor job until completion 177 | const completedJob = await this.monitorBatchJob(batchJob.id); 178 | 179 | // Retrieve and parse results 180 | const results = await this.retrieveResults(completedJob); 181 | 182 | // Cleanup 183 | await this.cleanup(inputFile, uploadedFile.id, completedJob.output_file_id); 184 | 185 | return results; 186 | } catch (error) { 187 | // Cleanup on error 188 | try { 189 | fs.unlinkSync(inputFile); 190 | } catch {} 191 | throw error; 192 | } 193 | } 194 | 195 | /** 196 | * Create batches from templates 197 | */ 198 | private createBatches(templates: MetadataRequest[]): MetadataRequest[][] { 199 | const batches: MetadataRequest[][] = []; 200 | 201 | for (let i = 0; i < templates.length; i += this.batchSize) { 202 | batches.push(templates.slice(i, i + this.batchSize)); 203 | } 204 | 205 | return batches; 206 | } 207 | 208 | /** 209 | * Create JSONL batch file 210 | */ 211 | private async createBatchFile(templates: MetadataRequest[], batchName: string): Promise<string> { 212 | const filename = path.join(this.outputDir, `${batchName}_${Date.now()}.jsonl`); 213 | const stream = fs.createWriteStream(filename); 214 | 215 | for (const template of templates) { 216 | const request = this.generator.createBatchRequest(template); 217 | stream.write(JSON.stringify(request) + '\n'); 218 | } 219 | 220 | stream.end(); 221 | 222 | // Wait for stream to finish 223 | await new Promise<void>((resolve, reject) => { 224 | stream.on('finish', () => resolve()); 225 | stream.on('error', reject); 226 | }); 227 | 228 | logger.debug(`Created batch file: ${filename} with ${templates.length} requests`); 229 | return filename; 230 | } 231 | 232 | /** 233 | * Upload file to OpenAI 234 | */ 235 | private async uploadFile(filepath: string): Promise<any> { 236 | const file = fs.createReadStream(filepath); 237 | const uploadedFile = await this.client.files.create({ 238 | file, 239 | purpose: 'batch' 240 | }); 241 | 242 | logger.debug(`Uploaded file: ${uploadedFile.id}`); 243 | return uploadedFile; 244 | } 245 | 246 | /** 247 | * Create batch job 248 | */ 249 | private async createBatchJob(fileId: string): Promise<any> { 250 | const batchJob = await this.client.batches.create({ 251 | input_file_id: fileId, 252 | endpoint: '/v1/chat/completions', 253 | completion_window: '24h' 254 | }); 255 | 256 | logger.info(`Created batch job: ${batchJob.id}`); 257 | return batchJob; 258 | } 259 | 260 | /** 261 | * Monitor batch job with fixed 1-minute polling interval 262 | */ 263 | private async monitorBatchJob(batchId: string): Promise<any> { 264 | const pollInterval = 60; // Check every 60 seconds (1 minute) 265 | let attempts = 0; 266 | const maxAttempts = 120; // 120 minutes max (2 hours) 267 | const startTime = Date.now(); 268 | let lastStatus = ''; 269 | 270 | while (attempts < maxAttempts) { 271 | const batchJob = await this.client.batches.retrieve(batchId); 272 | const elapsedMinutes = Math.floor((Date.now() - startTime) / 60000); 273 | 274 | // Log status on every check (not just on change) 275 | const statusSymbol = batchJob.status === 'in_progress' ? '⚙️' : 276 | batchJob.status === 'finalizing' ? '📦' : 277 | batchJob.status === 'validating' ? '🔍' : 278 | batchJob.status === 'completed' ? '✅' : 279 | batchJob.status === 'failed' ? '❌' : '⏳'; 280 | 281 | console.log(` ${statusSymbol} Batch ${batchId.slice(-8)}: ${batchJob.status} (${elapsedMinutes} min, check ${attempts + 1})`); 282 | 283 | if (batchJob.status !== lastStatus) { 284 | logger.info(`Batch ${batchId} status changed: ${lastStatus} -> ${batchJob.status}`); 285 | lastStatus = batchJob.status; 286 | } 287 | 288 | if (batchJob.status === 'completed') { 289 | console.log(` ✅ Batch ${batchId.slice(-8)} completed successfully in ${elapsedMinutes} minutes`); 290 | logger.info(`Batch job ${batchId} completed successfully`); 291 | return batchJob; 292 | } 293 | 294 | if (['failed', 'expired', 'cancelled'].includes(batchJob.status)) { 295 | logger.error(`Batch job ${batchId} failed with status: ${batchJob.status}`); 296 | throw new Error(`Batch job failed with status: ${batchJob.status}`); 297 | } 298 | 299 | // Wait before next check (always 1 minute) 300 | logger.debug(`Waiting ${pollInterval} seconds before next check...`); 301 | await this.sleep(pollInterval * 1000); 302 | 303 | attempts++; 304 | } 305 | 306 | throw new Error(`Batch job monitoring timed out after ${maxAttempts} minutes`); 307 | } 308 | 309 | /** 310 | * Retrieve and parse results 311 | */ 312 | private async retrieveResults(batchJob: any): Promise<MetadataResult[]> { 313 | const results: MetadataResult[] = []; 314 | 315 | // Check if we have an output file (successful results) 316 | if (batchJob.output_file_id) { 317 | const fileResponse = await this.client.files.content(batchJob.output_file_id); 318 | const fileContent = await fileResponse.text(); 319 | 320 | const lines = fileContent.trim().split('\n'); 321 | for (const line of lines) { 322 | if (!line) continue; 323 | try { 324 | const result = JSON.parse(line); 325 | const parsed = this.generator.parseResult(result); 326 | results.push(parsed); 327 | } catch (error) { 328 | logger.error('Error parsing result line:', error); 329 | } 330 | } 331 | logger.info(`Retrieved ${results.length} successful results from batch job`); 332 | } 333 | 334 | // Check if we have an error file (failed results) 335 | if (batchJob.error_file_id) { 336 | logger.warn(`Batch job has error file: ${batchJob.error_file_id}`); 337 | 338 | try { 339 | const errorResponse = await this.client.files.content(batchJob.error_file_id); 340 | const errorContent = await errorResponse.text(); 341 | 342 | // Save error file locally for debugging 343 | const errorFilePath = path.join(this.outputDir, `batch_${batchJob.id}_error.jsonl`); 344 | fs.writeFileSync(errorFilePath, errorContent); 345 | logger.warn(`Error file saved to: ${errorFilePath}`); 346 | 347 | // Parse errors and create default metadata for failed templates 348 | const errorLines = errorContent.trim().split('\n'); 349 | logger.warn(`Found ${errorLines.length} failed requests in error file`); 350 | 351 | for (const line of errorLines) { 352 | if (!line) continue; 353 | try { 354 | const errorResult = JSON.parse(line); 355 | const templateId = parseInt(errorResult.custom_id?.replace('template-', '') || '0'); 356 | 357 | if (templateId > 0) { 358 | const errorMessage = errorResult.response?.body?.error?.message || 359 | errorResult.error?.message || 360 | 'Unknown error'; 361 | 362 | logger.debug(`Template ${templateId} failed: ${errorMessage}`); 363 | 364 | // Use getDefaultMetadata() from generator (it's private but accessible via bracket notation) 365 | const defaultMeta = (this.generator as any).getDefaultMetadata(); 366 | results.push({ 367 | templateId, 368 | metadata: defaultMeta, 369 | error: errorMessage 370 | }); 371 | } 372 | } catch (parseError) { 373 | logger.error('Error parsing error line:', parseError); 374 | } 375 | } 376 | } catch (error) { 377 | logger.error('Failed to process error file:', error); 378 | } 379 | } 380 | 381 | // If we have no results at all, something is very wrong 382 | if (results.length === 0 && !batchJob.output_file_id && !batchJob.error_file_id) { 383 | throw new Error('No output file or error file available for batch job'); 384 | } 385 | 386 | logger.info(`Total results (successful + failed): ${results.length}`); 387 | return results; 388 | } 389 | 390 | /** 391 | * Cleanup temporary files 392 | */ 393 | private async cleanup(localFile: string, inputFileId: string, outputFileId?: string): Promise<void> { 394 | // Delete local file 395 | try { 396 | fs.unlinkSync(localFile); 397 | logger.debug(`Deleted local file: ${localFile}`); 398 | } catch (error) { 399 | logger.warn(`Failed to delete local file: ${localFile}`, error); 400 | } 401 | 402 | // Delete uploaded files from OpenAI 403 | try { 404 | await this.client.files.del(inputFileId); 405 | logger.debug(`Deleted input file from OpenAI: ${inputFileId}`); 406 | } catch (error) { 407 | logger.warn(`Failed to delete input file from OpenAI: ${inputFileId}`, error); 408 | } 409 | 410 | if (outputFileId) { 411 | try { 412 | await this.client.files.del(outputFileId); 413 | logger.debug(`Deleted output file from OpenAI: ${outputFileId}`); 414 | } catch (error) { 415 | logger.warn(`Failed to delete output file from OpenAI: ${outputFileId}`, error); 416 | } 417 | } 418 | } 419 | 420 | /** 421 | * Sleep helper 422 | */ 423 | private sleep(ms: number): Promise<void> { 424 | return new Promise(resolve => setTimeout(resolve, ms)); 425 | } 426 | } ``` -------------------------------------------------------------------------------- /tests/unit/mcp/tools-documentation.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { 3 | getToolDocumentation, 4 | getToolsOverview, 5 | searchToolDocumentation, 6 | getToolsByCategory, 7 | getAllCategories 8 | } from '@/mcp/tools-documentation'; 9 | 10 | // Mock the tool-docs import 11 | vi.mock('@/mcp/tool-docs', () => ({ 12 | toolsDocumentation: { 13 | search_nodes: { 14 | name: 'search_nodes', 15 | category: 'discovery', 16 | essentials: { 17 | description: 'Search nodes by keywords', 18 | keyParameters: ['query', 'mode', 'limit'], 19 | example: 'search_nodes({query: "slack"})', 20 | performance: 'Instant (<10ms)', 21 | tips: ['Use single words for precision', 'Try FUZZY mode for typos'] 22 | }, 23 | full: { 24 | description: 'Full-text search across all n8n nodes with multiple matching modes', 25 | parameters: { 26 | query: { 27 | type: 'string', 28 | description: 'Search terms', 29 | required: true 30 | }, 31 | mode: { 32 | type: 'string', 33 | description: 'Search mode', 34 | enum: ['OR', 'AND', 'FUZZY'], 35 | default: 'OR' 36 | }, 37 | limit: { 38 | type: 'number', 39 | description: 'Max results', 40 | default: 20 41 | } 42 | }, 43 | returns: 'Array of matching nodes with metadata', 44 | examples: [ 45 | 'search_nodes({query: "webhook"})', 46 | 'search_nodes({query: "http request", mode: "AND"})' 47 | ], 48 | useCases: ['Finding integration nodes', 'Discovering available triggers'], 49 | performance: 'Instant - uses in-memory index', 50 | bestPractices: ['Start with single words', 'Use FUZZY for uncertain names'], 51 | pitfalls: ['Overly specific queries may return no results'], 52 | relatedTools: ['list_nodes', 'get_node_info'] 53 | } 54 | }, 55 | validate_workflow: { 56 | name: 'validate_workflow', 57 | category: 'validation', 58 | essentials: { 59 | description: 'Validate complete workflow structure', 60 | keyParameters: ['workflow', 'options'], 61 | example: 'validate_workflow(workflow)', 62 | performance: 'Moderate (100-500ms)', 63 | tips: ['Run before deployment', 'Check all validation types'] 64 | }, 65 | full: { 66 | description: 'Comprehensive workflow validation', 67 | parameters: { 68 | workflow: { 69 | type: 'object', 70 | description: 'Workflow JSON', 71 | required: true 72 | }, 73 | options: { 74 | type: 'object', 75 | description: 'Validation options' 76 | } 77 | }, 78 | returns: 'Validation results with errors and warnings', 79 | examples: ['validate_workflow(workflow)'], 80 | useCases: ['Pre-deployment checks', 'CI/CD validation'], 81 | performance: 'Depends on workflow complexity', 82 | bestPractices: ['Validate before saving', 'Fix errors first'], 83 | pitfalls: ['Large workflows may take time'], 84 | relatedTools: ['validate_node_operation'] 85 | } 86 | }, 87 | get_node_essentials: { 88 | name: 'get_node_essentials', 89 | category: 'configuration', 90 | essentials: { 91 | description: 'Get essential node properties only', 92 | keyParameters: ['nodeType'], 93 | example: 'get_node_essentials("nodes-base.slack")', 94 | performance: 'Fast (<100ms)', 95 | tips: ['Use this before get_node_info', 'Returns 95% smaller payload'] 96 | }, 97 | full: { 98 | description: 'Returns 10-20 most important properties', 99 | parameters: { 100 | nodeType: { 101 | type: 'string', 102 | description: 'Full node type with prefix', 103 | required: true 104 | } 105 | }, 106 | returns: 'Essential properties with examples', 107 | examples: ['get_node_essentials("nodes-base.httpRequest")'], 108 | useCases: ['Quick configuration', 'Property discovery'], 109 | performance: 'Fast - pre-filtered data', 110 | bestPractices: ['Always try essentials first'], 111 | pitfalls: ['May not include all advanced options'], 112 | relatedTools: ['get_node_info'] 113 | } 114 | } 115 | } 116 | })); 117 | 118 | // No need to mock package.json - let the actual module read it 119 | 120 | describe('tools-documentation', () => { 121 | beforeEach(() => { 122 | vi.clearAllMocks(); 123 | }); 124 | 125 | describe('getToolDocumentation', () => { 126 | describe('essentials mode', () => { 127 | it('should return essential documentation for existing tool', () => { 128 | const doc = getToolDocumentation('search_nodes', 'essentials'); 129 | 130 | expect(doc).toContain('# search_nodes'); 131 | expect(doc).toContain('Search nodes by keywords'); 132 | expect(doc).toContain('**Example**: search_nodes({query: "slack"})'); 133 | expect(doc).toContain('**Key parameters**: query, mode, limit'); 134 | expect(doc).toContain('**Performance**: Instant (<10ms)'); 135 | expect(doc).toContain('- Use single words for precision'); 136 | expect(doc).toContain('- Try FUZZY mode for typos'); 137 | expect(doc).toContain('For full documentation, use: tools_documentation({topic: "search_nodes", depth: "full"})'); 138 | }); 139 | 140 | it('should return error message for unknown tool', () => { 141 | const doc = getToolDocumentation('unknown_tool', 'essentials'); 142 | expect(doc).toBe("Tool 'unknown_tool' not found. Use tools_documentation() to see available tools."); 143 | }); 144 | 145 | it('should use essentials as default depth', () => { 146 | const docDefault = getToolDocumentation('search_nodes'); 147 | const docEssentials = getToolDocumentation('search_nodes', 'essentials'); 148 | expect(docDefault).toBe(docEssentials); 149 | }); 150 | }); 151 | 152 | describe('full mode', () => { 153 | it('should return complete documentation for existing tool', () => { 154 | const doc = getToolDocumentation('search_nodes', 'full'); 155 | 156 | expect(doc).toContain('# search_nodes'); 157 | expect(doc).toContain('Full-text search across all n8n nodes'); 158 | expect(doc).toContain('## Parameters'); 159 | expect(doc).toContain('- **query** (string, required): Search terms'); 160 | expect(doc).toContain('- **mode** (string): Search mode'); 161 | expect(doc).toContain('- **limit** (number): Max results'); 162 | expect(doc).toContain('## Returns'); 163 | expect(doc).toContain('Array of matching nodes with metadata'); 164 | expect(doc).toContain('## Examples'); 165 | expect(doc).toContain('search_nodes({query: "webhook"})'); 166 | expect(doc).toContain('## Common Use Cases'); 167 | expect(doc).toContain('- Finding integration nodes'); 168 | expect(doc).toContain('## Performance'); 169 | expect(doc).toContain('Instant - uses in-memory index'); 170 | expect(doc).toContain('## Best Practices'); 171 | expect(doc).toContain('- Start with single words'); 172 | expect(doc).toContain('## Common Pitfalls'); 173 | expect(doc).toContain('- Overly specific queries'); 174 | expect(doc).toContain('## Related Tools'); 175 | expect(doc).toContain('- list_nodes'); 176 | }); 177 | }); 178 | 179 | describe('special documentation topics', () => { 180 | it('should return JavaScript Code node guide for javascript_code_node_guide', () => { 181 | const doc = getToolDocumentation('javascript_code_node_guide', 'essentials'); 182 | expect(doc).toContain('# JavaScript Code Node Guide'); 183 | expect(doc).toContain('$input.all()'); 184 | expect(doc).toContain('DateTime'); 185 | }); 186 | 187 | it('should return Python Code node guide for python_code_node_guide', () => { 188 | const doc = getToolDocumentation('python_code_node_guide', 'essentials'); 189 | expect(doc).toContain('# Python Code Node Guide'); 190 | expect(doc).toContain('_input.all()'); 191 | expect(doc).toContain('_json'); 192 | }); 193 | 194 | it('should return full JavaScript guide when requested', () => { 195 | const doc = getToolDocumentation('javascript_code_node_guide', 'full'); 196 | expect(doc).toContain('# JavaScript Code Node Complete Guide'); 197 | expect(doc).toContain('## Data Access Patterns'); 198 | expect(doc).toContain('## Available Built-in Functions'); 199 | expect(doc).toContain('$helpers.httpRequest'); 200 | }); 201 | 202 | it('should return full Python guide when requested', () => { 203 | const doc = getToolDocumentation('python_code_node_guide', 'full'); 204 | expect(doc).toContain('# Python Code Node Complete Guide'); 205 | expect(doc).toContain('## Available Built-in Modules'); 206 | expect(doc).toContain('## Limitations & Workarounds'); 207 | expect(doc).toContain('import json'); 208 | }); 209 | }); 210 | }); 211 | 212 | describe('getToolsOverview', () => { 213 | describe('essentials mode', () => { 214 | it('should return essential overview with categories', () => { 215 | const overview = getToolsOverview('essentials'); 216 | 217 | expect(overview).toContain('# n8n MCP Tools Reference'); 218 | expect(overview).toContain('## Important: Compatibility Notice'); 219 | // The tools-documentation module dynamically reads version from package.json 220 | // so we need to read it the same way to match 221 | const packageJson = require('../../../package.json'); 222 | const n8nVersion = packageJson.dependencies.n8n.replace(/[^0-9.]/g, ''); 223 | expect(overview).toContain(`n8n version ${n8nVersion}`); 224 | expect(overview).toContain('## Code Node Configuration'); 225 | expect(overview).toContain('## Standard Workflow Pattern'); 226 | expect(overview).toContain('**Discovery Tools**'); 227 | expect(overview).toContain('**Configuration Tools**'); 228 | expect(overview).toContain('**Validation Tools**'); 229 | expect(overview).toContain('## Performance Characteristics'); 230 | expect(overview).toContain('- Instant (<10ms)'); 231 | expect(overview).toContain('tools_documentation({topic: "tool_name", depth: "full"})'); 232 | }); 233 | 234 | it('should use essentials as default', () => { 235 | const overviewDefault = getToolsOverview(); 236 | const overviewEssentials = getToolsOverview('essentials'); 237 | expect(overviewDefault).toBe(overviewEssentials); 238 | }); 239 | }); 240 | 241 | describe('full mode', () => { 242 | it('should return complete overview with all tools', () => { 243 | const overview = getToolsOverview('full'); 244 | 245 | expect(overview).toContain('# n8n MCP Tools - Complete Reference'); 246 | expect(overview).toContain('## All Available Tools by Category'); 247 | expect(overview).toContain('### Discovery'); 248 | expect(overview).toContain('- **search_nodes**: Search nodes by keywords'); 249 | expect(overview).toContain('### Validation'); 250 | expect(overview).toContain('- **validate_workflow**: Validate complete workflow structure'); 251 | expect(overview).toContain('## Usage Notes'); 252 | }); 253 | }); 254 | }); 255 | 256 | describe('searchToolDocumentation', () => { 257 | it('should find tools matching keyword in name', () => { 258 | const results = searchToolDocumentation('search'); 259 | expect(results).toContain('search_nodes'); 260 | }); 261 | 262 | it('should find tools matching keyword in description', () => { 263 | const results = searchToolDocumentation('workflow'); 264 | expect(results).toContain('validate_workflow'); 265 | }); 266 | 267 | it('should be case insensitive', () => { 268 | const resultsLower = searchToolDocumentation('search'); 269 | const resultsUpper = searchToolDocumentation('SEARCH'); 270 | expect(resultsLower).toEqual(resultsUpper); 271 | }); 272 | 273 | it('should return empty array for no matches', () => { 274 | const results = searchToolDocumentation('nonexistentxyz123'); 275 | expect(results).toEqual([]); 276 | }); 277 | 278 | it('should search in both essentials and full descriptions', () => { 279 | const results = searchToolDocumentation('validation'); 280 | expect(results.length).toBeGreaterThan(0); 281 | }); 282 | }); 283 | 284 | describe('getToolsByCategory', () => { 285 | it('should return tools for discovery category', () => { 286 | const tools = getToolsByCategory('discovery'); 287 | expect(tools).toContain('search_nodes'); 288 | }); 289 | 290 | it('should return tools for validation category', () => { 291 | const tools = getToolsByCategory('validation'); 292 | expect(tools).toContain('validate_workflow'); 293 | }); 294 | 295 | it('should return tools for configuration category', () => { 296 | const tools = getToolsByCategory('configuration'); 297 | expect(tools).toContain('get_node_essentials'); 298 | }); 299 | 300 | it('should return empty array for unknown category', () => { 301 | const tools = getToolsByCategory('unknown_category'); 302 | expect(tools).toEqual([]); 303 | }); 304 | }); 305 | 306 | describe('getAllCategories', () => { 307 | it('should return all unique categories', () => { 308 | const categories = getAllCategories(); 309 | expect(categories).toContain('discovery'); 310 | expect(categories).toContain('validation'); 311 | expect(categories).toContain('configuration'); 312 | }); 313 | 314 | it('should not have duplicates', () => { 315 | const categories = getAllCategories(); 316 | const uniqueCategories = new Set(categories); 317 | expect(categories.length).toBe(uniqueCategories.size); 318 | }); 319 | 320 | it('should return non-empty array', () => { 321 | const categories = getAllCategories(); 322 | expect(categories.length).toBeGreaterThan(0); 323 | }); 324 | }); 325 | 326 | describe('Error Handling', () => { 327 | it('should handle missing tool gracefully', () => { 328 | const doc = getToolDocumentation('missing_tool'); 329 | expect(doc).toContain("Tool 'missing_tool' not found"); 330 | expect(doc).toContain('Use tools_documentation()'); 331 | }); 332 | 333 | it('should handle empty search query', () => { 334 | const results = searchToolDocumentation(''); 335 | // Should match all tools since empty string is in everything 336 | expect(results.length).toBeGreaterThan(0); 337 | }); 338 | }); 339 | 340 | describe('Documentation Quality', () => { 341 | it('should format parameters correctly in full mode', () => { 342 | const doc = getToolDocumentation('search_nodes', 'full'); 343 | 344 | // Check parameter formatting 345 | expect(doc).toMatch(/- \*\*query\*\* \(string, required\): Search terms/); 346 | expect(doc).toMatch(/- \*\*mode\*\* \(string\): Search mode/); 347 | expect(doc).toMatch(/- \*\*limit\*\* \(number\): Max results/); 348 | }); 349 | 350 | it('should include code blocks for examples', () => { 351 | const doc = getToolDocumentation('search_nodes', 'full'); 352 | expect(doc).toContain('```javascript'); 353 | expect(doc).toContain('```'); 354 | }); 355 | 356 | it('should have consistent section headers', () => { 357 | const doc = getToolDocumentation('search_nodes', 'full'); 358 | const expectedSections = [ 359 | '## Parameters', 360 | '## Returns', 361 | '## Examples', 362 | '## Common Use Cases', 363 | '## Performance', 364 | '## Best Practices', 365 | '## Common Pitfalls', 366 | '## Related Tools' 367 | ]; 368 | 369 | expectedSections.forEach(section => { 370 | expect(doc).toContain(section); 371 | }); 372 | }); 373 | }); 374 | }); ``` -------------------------------------------------------------------------------- /src/mcp/tools-n8n-manager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ToolDefinition } from '../types'; 2 | 3 | /** 4 | * n8n Management Tools 5 | * 6 | * These tools enable AI agents to manage n8n workflows through the n8n API. 7 | * They require N8N_API_URL and N8N_API_KEY to be configured. 8 | */ 9 | export const n8nManagementTools: ToolDefinition[] = [ 10 | // Workflow Management Tools 11 | { 12 | name: 'n8n_create_workflow', 13 | description: `Create workflow. Requires: name, nodes[], connections{}. Created inactive. Returns workflow with ID.`, 14 | inputSchema: { 15 | type: 'object', 16 | properties: { 17 | name: { 18 | type: 'string', 19 | description: 'Workflow name (required)' 20 | }, 21 | nodes: { 22 | type: 'array', 23 | description: 'Array of workflow nodes. Each node must have: id, name, type, typeVersion, position, and parameters', 24 | items: { 25 | type: 'object', 26 | required: ['id', 'name', 'type', 'typeVersion', 'position', 'parameters'], 27 | properties: { 28 | id: { type: 'string' }, 29 | name: { type: 'string' }, 30 | type: { type: 'string' }, 31 | typeVersion: { type: 'number' }, 32 | position: { 33 | type: 'array', 34 | items: { type: 'number' }, 35 | minItems: 2, 36 | maxItems: 2 37 | }, 38 | parameters: { type: 'object' }, 39 | credentials: { type: 'object' }, 40 | disabled: { type: 'boolean' }, 41 | notes: { type: 'string' }, 42 | continueOnFail: { type: 'boolean' }, 43 | retryOnFail: { type: 'boolean' }, 44 | maxTries: { type: 'number' }, 45 | waitBetweenTries: { type: 'number' } 46 | } 47 | } 48 | }, 49 | connections: { 50 | type: 'object', 51 | description: 'Workflow connections object. Keys are source node IDs, values define output connections' 52 | }, 53 | settings: { 54 | type: 'object', 55 | description: 'Optional workflow settings (execution order, timezone, error handling)', 56 | properties: { 57 | executionOrder: { type: 'string', enum: ['v0', 'v1'] }, 58 | timezone: { type: 'string' }, 59 | saveDataErrorExecution: { type: 'string', enum: ['all', 'none'] }, 60 | saveDataSuccessExecution: { type: 'string', enum: ['all', 'none'] }, 61 | saveManualExecutions: { type: 'boolean' }, 62 | saveExecutionProgress: { type: 'boolean' }, 63 | executionTimeout: { type: 'number' }, 64 | errorWorkflow: { type: 'string' } 65 | } 66 | } 67 | }, 68 | required: ['name', 'nodes', 'connections'] 69 | } 70 | }, 71 | { 72 | name: 'n8n_get_workflow', 73 | description: `Get a workflow by ID. Returns the complete workflow including nodes, connections, and settings.`, 74 | inputSchema: { 75 | type: 'object', 76 | properties: { 77 | id: { 78 | type: 'string', 79 | description: 'Workflow ID' 80 | } 81 | }, 82 | required: ['id'] 83 | } 84 | }, 85 | { 86 | name: 'n8n_get_workflow_details', 87 | description: `Get workflow details with metadata, version, execution stats. More info than get_workflow.`, 88 | inputSchema: { 89 | type: 'object', 90 | properties: { 91 | id: { 92 | type: 'string', 93 | description: 'Workflow ID' 94 | } 95 | }, 96 | required: ['id'] 97 | } 98 | }, 99 | { 100 | name: 'n8n_get_workflow_structure', 101 | description: `Get workflow structure: nodes and connections only. No parameter details.`, 102 | inputSchema: { 103 | type: 'object', 104 | properties: { 105 | id: { 106 | type: 'string', 107 | description: 'Workflow ID' 108 | } 109 | }, 110 | required: ['id'] 111 | } 112 | }, 113 | { 114 | name: 'n8n_get_workflow_minimal', 115 | description: `Get minimal info: ID, name, active status, tags. Fast for listings.`, 116 | inputSchema: { 117 | type: 'object', 118 | properties: { 119 | id: { 120 | type: 'string', 121 | description: 'Workflow ID' 122 | } 123 | }, 124 | required: ['id'] 125 | } 126 | }, 127 | { 128 | name: 'n8n_update_full_workflow', 129 | description: `Full workflow update. Requires complete nodes[] and connections{}. For incremental use n8n_update_partial_workflow.`, 130 | inputSchema: { 131 | type: 'object', 132 | properties: { 133 | id: { 134 | type: 'string', 135 | description: 'Workflow ID to update' 136 | }, 137 | name: { 138 | type: 'string', 139 | description: 'New workflow name' 140 | }, 141 | nodes: { 142 | type: 'array', 143 | description: 'Complete array of workflow nodes (required if modifying workflow structure)', 144 | items: { 145 | type: 'object', 146 | additionalProperties: true 147 | } 148 | }, 149 | connections: { 150 | type: 'object', 151 | description: 'Complete connections object (required if modifying workflow structure)' 152 | }, 153 | settings: { 154 | type: 'object', 155 | description: 'Workflow settings to update' 156 | } 157 | }, 158 | required: ['id'] 159 | } 160 | }, 161 | { 162 | name: 'n8n_update_partial_workflow', 163 | description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag. See tools_documentation("n8n_update_partial_workflow", "full") for details.`, 164 | inputSchema: { 165 | type: 'object', 166 | additionalProperties: true, // Allow any extra properties Claude Desktop might add 167 | properties: { 168 | id: { 169 | type: 'string', 170 | description: 'Workflow ID to update' 171 | }, 172 | operations: { 173 | type: 'array', 174 | description: 'Array of diff operations to apply. Each operation must have a "type" field and relevant properties for that operation type.', 175 | items: { 176 | type: 'object', 177 | additionalProperties: true 178 | } 179 | }, 180 | validateOnly: { 181 | type: 'boolean', 182 | description: 'If true, only validate operations without applying them' 183 | }, 184 | continueOnError: { 185 | type: 'boolean', 186 | description: 'If true, apply valid operations even if some fail (best-effort mode). Returns applied and failed operation indices. Default: false (atomic)' 187 | } 188 | }, 189 | required: ['id', 'operations'] 190 | } 191 | }, 192 | { 193 | name: 'n8n_delete_workflow', 194 | description: `Permanently delete a workflow. This action cannot be undone.`, 195 | inputSchema: { 196 | type: 'object', 197 | properties: { 198 | id: { 199 | type: 'string', 200 | description: 'Workflow ID to delete' 201 | } 202 | }, 203 | required: ['id'] 204 | } 205 | }, 206 | { 207 | name: 'n8n_list_workflows', 208 | description: `List workflows (minimal metadata only). Returns id/name/active/dates/tags. Check hasMore/nextCursor for pagination.`, 209 | inputSchema: { 210 | type: 'object', 211 | properties: { 212 | limit: { 213 | type: 'number', 214 | description: 'Number of workflows to return (1-100, default: 100)' 215 | }, 216 | cursor: { 217 | type: 'string', 218 | description: 'Pagination cursor from previous response' 219 | }, 220 | active: { 221 | type: 'boolean', 222 | description: 'Filter by active status' 223 | }, 224 | tags: { 225 | type: 'array', 226 | items: { type: 'string' }, 227 | description: 'Filter by tags (exact match)' 228 | }, 229 | projectId: { 230 | type: 'string', 231 | description: 'Filter by project ID (enterprise feature)' 232 | }, 233 | excludePinnedData: { 234 | type: 'boolean', 235 | description: 'Exclude pinned data from response (default: true)' 236 | } 237 | } 238 | } 239 | }, 240 | { 241 | name: 'n8n_validate_workflow', 242 | description: `Validate workflow by ID. Checks nodes, connections, expressions. Returns errors/warnings/suggestions.`, 243 | inputSchema: { 244 | type: 'object', 245 | properties: { 246 | id: { 247 | type: 'string', 248 | description: 'Workflow ID to validate' 249 | }, 250 | options: { 251 | type: 'object', 252 | description: 'Validation options', 253 | properties: { 254 | validateNodes: { 255 | type: 'boolean', 256 | description: 'Validate node configurations (default: true)' 257 | }, 258 | validateConnections: { 259 | type: 'boolean', 260 | description: 'Validate workflow connections (default: true)' 261 | }, 262 | validateExpressions: { 263 | type: 'boolean', 264 | description: 'Validate n8n expressions (default: true)' 265 | }, 266 | profile: { 267 | type: 'string', 268 | enum: ['minimal', 'runtime', 'ai-friendly', 'strict'], 269 | description: 'Validation profile to use (default: runtime)' 270 | } 271 | } 272 | } 273 | }, 274 | required: ['id'] 275 | } 276 | }, 277 | { 278 | name: 'n8n_autofix_workflow', 279 | description: `Automatically fix common workflow validation errors. Preview fixes or apply them. Fixes expression format, typeVersion, error output config, webhook paths.`, 280 | inputSchema: { 281 | type: 'object', 282 | properties: { 283 | id: { 284 | type: 'string', 285 | description: 'Workflow ID to fix' 286 | }, 287 | applyFixes: { 288 | type: 'boolean', 289 | description: 'Apply fixes to workflow (default: false - preview mode)' 290 | }, 291 | fixTypes: { 292 | type: 'array', 293 | description: 'Types of fixes to apply (default: all)', 294 | items: { 295 | type: 'string', 296 | enum: ['expression-format', 'typeversion-correction', 'error-output-config', 'node-type-correction', 'webhook-missing-path'] 297 | } 298 | }, 299 | confidenceThreshold: { 300 | type: 'string', 301 | enum: ['high', 'medium', 'low'], 302 | description: 'Minimum confidence level for fixes (default: medium)' 303 | }, 304 | maxFixes: { 305 | type: 'number', 306 | description: 'Maximum number of fixes to apply (default: 50)' 307 | } 308 | }, 309 | required: ['id'] 310 | } 311 | }, 312 | 313 | // Execution Management Tools 314 | { 315 | name: 'n8n_trigger_webhook_workflow', 316 | description: `Trigger workflow via webhook. Must be ACTIVE with Webhook node. Method must match config.`, 317 | inputSchema: { 318 | type: 'object', 319 | properties: { 320 | webhookUrl: { 321 | type: 'string', 322 | description: 'Full webhook URL from n8n workflow (e.g., https://n8n.example.com/webhook/abc-def-ghi)' 323 | }, 324 | httpMethod: { 325 | type: 'string', 326 | enum: ['GET', 'POST', 'PUT', 'DELETE'], 327 | description: 'HTTP method (must match webhook configuration, often GET)' 328 | }, 329 | data: { 330 | type: 'object', 331 | description: 'Data to send with the webhook request' 332 | }, 333 | headers: { 334 | type: 'object', 335 | description: 'Additional HTTP headers' 336 | }, 337 | waitForResponse: { 338 | type: 'boolean', 339 | description: 'Wait for workflow completion (default: true)' 340 | } 341 | }, 342 | required: ['webhookUrl'] 343 | } 344 | }, 345 | { 346 | name: 'n8n_get_execution', 347 | description: `Get execution details with smart filtering. RECOMMENDED: Use mode='preview' first to assess data size. 348 | Examples: 349 | - {id, mode:'preview'} - Structure & counts (fast, no data) 350 | - {id, mode:'summary'} - 2 samples per node (default) 351 | - {id, mode:'filtered', itemsLimit:5} - 5 items per node 352 | - {id, nodeNames:['HTTP Request']} - Specific node only 353 | - {id, mode:'full'} - Complete data (use with caution)`, 354 | inputSchema: { 355 | type: 'object', 356 | properties: { 357 | id: { 358 | type: 'string', 359 | description: 'Execution ID' 360 | }, 361 | mode: { 362 | type: 'string', 363 | enum: ['preview', 'summary', 'filtered', 'full'], 364 | description: 'Data retrieval mode: preview=structure only, summary=2 items, filtered=custom, full=all data' 365 | }, 366 | nodeNames: { 367 | type: 'array', 368 | items: { type: 'string' }, 369 | description: 'Filter to specific nodes by name (for filtered mode)' 370 | }, 371 | itemsLimit: { 372 | type: 'number', 373 | description: 'Items per node: 0=structure only, 2=default, -1=unlimited (for filtered mode)' 374 | }, 375 | includeInputData: { 376 | type: 'boolean', 377 | description: 'Include input data in addition to output (default: false)' 378 | }, 379 | includeData: { 380 | type: 'boolean', 381 | description: 'Legacy: Include execution data. Maps to mode=summary if true (deprecated, use mode instead)' 382 | } 383 | }, 384 | required: ['id'] 385 | } 386 | }, 387 | { 388 | name: 'n8n_list_executions', 389 | description: `List workflow executions (returns up to limit). Check hasMore/nextCursor for pagination.`, 390 | inputSchema: { 391 | type: 'object', 392 | properties: { 393 | limit: { 394 | type: 'number', 395 | description: 'Number of executions to return (1-100, default: 100)' 396 | }, 397 | cursor: { 398 | type: 'string', 399 | description: 'Pagination cursor from previous response' 400 | }, 401 | workflowId: { 402 | type: 'string', 403 | description: 'Filter by workflow ID' 404 | }, 405 | projectId: { 406 | type: 'string', 407 | description: 'Filter by project ID (enterprise feature)' 408 | }, 409 | status: { 410 | type: 'string', 411 | enum: ['success', 'error', 'waiting'], 412 | description: 'Filter by execution status' 413 | }, 414 | includeData: { 415 | type: 'boolean', 416 | description: 'Include execution data (default: false)' 417 | } 418 | } 419 | } 420 | }, 421 | { 422 | name: 'n8n_delete_execution', 423 | description: `Delete an execution record. This only removes the execution history, not any data processed.`, 424 | inputSchema: { 425 | type: 'object', 426 | properties: { 427 | id: { 428 | type: 'string', 429 | description: 'Execution ID to delete' 430 | } 431 | }, 432 | required: ['id'] 433 | } 434 | }, 435 | 436 | // System Tools 437 | { 438 | name: 'n8n_health_check', 439 | description: `Check n8n instance health and API connectivity. Returns status and available features.`, 440 | inputSchema: { 441 | type: 'object', 442 | properties: {} 443 | } 444 | }, 445 | { 446 | name: 'n8n_list_available_tools', 447 | description: `List available n8n tools and capabilities.`, 448 | inputSchema: { 449 | type: 'object', 450 | properties: {} 451 | } 452 | }, 453 | { 454 | name: 'n8n_diagnostic', 455 | description: `Diagnose n8n API config. Shows tool status, API connectivity, env vars. Helps troubleshoot missing tools.`, 456 | inputSchema: { 457 | type: 'object', 458 | properties: { 459 | verbose: { 460 | type: 'boolean', 461 | description: 'Include detailed debug information (default: false)' 462 | } 463 | } 464 | } 465 | } 466 | ]; ``` -------------------------------------------------------------------------------- /src/utils/fixed-collection-validator.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Generic utility for validating and fixing fixedCollection structures in n8n nodes 3 | * Prevents the "propertyValues[itemName] is not iterable" error 4 | */ 5 | 6 | // Type definitions for node configurations 7 | export type NodeConfigValue = string | number | boolean | null | undefined | NodeConfig | NodeConfigValue[]; 8 | 9 | export interface NodeConfig { 10 | [key: string]: NodeConfigValue; 11 | } 12 | 13 | export interface FixedCollectionPattern { 14 | nodeType: string; 15 | property: string; 16 | subProperty?: string; 17 | expectedStructure: string; 18 | invalidPatterns: string[]; 19 | } 20 | 21 | export interface FixedCollectionValidationResult { 22 | isValid: boolean; 23 | errors: Array<{ 24 | pattern: string; 25 | message: string; 26 | fix: string; 27 | }>; 28 | autofix?: NodeConfig | NodeConfigValue[]; 29 | } 30 | 31 | export class FixedCollectionValidator { 32 | /** 33 | * Type guard to check if value is a NodeConfig 34 | */ 35 | private static isNodeConfig(value: NodeConfigValue): value is NodeConfig { 36 | return typeof value === 'object' && value !== null && !Array.isArray(value); 37 | } 38 | 39 | /** 40 | * Safely get nested property value 41 | */ 42 | private static getNestedValue(obj: NodeConfig, path: string): NodeConfigValue | undefined { 43 | const parts = path.split('.'); 44 | let current: NodeConfigValue = obj; 45 | 46 | for (const part of parts) { 47 | if (!this.isNodeConfig(current)) { 48 | return undefined; 49 | } 50 | current = current[part]; 51 | } 52 | 53 | return current; 54 | } 55 | /** 56 | * Known problematic patterns for various n8n nodes 57 | */ 58 | private static readonly KNOWN_PATTERNS: FixedCollectionPattern[] = [ 59 | // Conditional nodes (already fixed) 60 | { 61 | nodeType: 'switch', 62 | property: 'rules', 63 | expectedStructure: 'rules.values array', 64 | invalidPatterns: ['rules.conditions', 'rules.conditions.values'] 65 | }, 66 | { 67 | nodeType: 'if', 68 | property: 'conditions', 69 | expectedStructure: 'conditions array/object', 70 | invalidPatterns: ['conditions.values'] 71 | }, 72 | { 73 | nodeType: 'filter', 74 | property: 'conditions', 75 | expectedStructure: 'conditions array/object', 76 | invalidPatterns: ['conditions.values'] 77 | }, 78 | // New nodes identified by research 79 | { 80 | nodeType: 'summarize', 81 | property: 'fieldsToSummarize', 82 | subProperty: 'values', 83 | expectedStructure: 'fieldsToSummarize.values array', 84 | invalidPatterns: ['fieldsToSummarize.values.values'] 85 | }, 86 | { 87 | nodeType: 'comparedatasets', 88 | property: 'mergeByFields', 89 | subProperty: 'values', 90 | expectedStructure: 'mergeByFields.values array', 91 | invalidPatterns: ['mergeByFields.values.values'] 92 | }, 93 | { 94 | nodeType: 'sort', 95 | property: 'sortFieldsUi', 96 | subProperty: 'sortField', 97 | expectedStructure: 'sortFieldsUi.sortField array', 98 | invalidPatterns: ['sortFieldsUi.sortField.values'] 99 | }, 100 | { 101 | nodeType: 'aggregate', 102 | property: 'fieldsToAggregate', 103 | subProperty: 'fieldToAggregate', 104 | expectedStructure: 'fieldsToAggregate.fieldToAggregate array', 105 | invalidPatterns: ['fieldsToAggregate.fieldToAggregate.values'] 106 | }, 107 | { 108 | nodeType: 'set', 109 | property: 'fields', 110 | subProperty: 'values', 111 | expectedStructure: 'fields.values array', 112 | invalidPatterns: ['fields.values.values'] 113 | }, 114 | { 115 | nodeType: 'html', 116 | property: 'extractionValues', 117 | subProperty: 'values', 118 | expectedStructure: 'extractionValues.values array', 119 | invalidPatterns: ['extractionValues.values.values'] 120 | }, 121 | { 122 | nodeType: 'httprequest', 123 | property: 'body', 124 | subProperty: 'parameters', 125 | expectedStructure: 'body.parameters array', 126 | invalidPatterns: ['body.parameters.values'] 127 | }, 128 | { 129 | nodeType: 'airtable', 130 | property: 'sort', 131 | subProperty: 'sortField', 132 | expectedStructure: 'sort.sortField array', 133 | invalidPatterns: ['sort.sortField.values'] 134 | } 135 | ]; 136 | 137 | /** 138 | * Validate a node configuration for fixedCollection issues 139 | * Includes protection against circular references 140 | */ 141 | static validate( 142 | nodeType: string, 143 | config: NodeConfig 144 | ): FixedCollectionValidationResult { 145 | // Early return for non-object configs 146 | if (typeof config !== 'object' || config === null || Array.isArray(config)) { 147 | return { isValid: true, errors: [] }; 148 | } 149 | 150 | const normalizedNodeType = this.normalizeNodeType(nodeType); 151 | const pattern = this.getPatternForNode(normalizedNodeType); 152 | 153 | if (!pattern) { 154 | return { isValid: true, errors: [] }; 155 | } 156 | 157 | const result: FixedCollectionValidationResult = { 158 | isValid: true, 159 | errors: [] 160 | }; 161 | 162 | // Check for invalid patterns 163 | for (const invalidPattern of pattern.invalidPatterns) { 164 | if (this.hasInvalidStructure(config, invalidPattern)) { 165 | result.isValid = false; 166 | result.errors.push({ 167 | pattern: invalidPattern, 168 | message: `Invalid structure for nodes-base.${pattern.nodeType} node: found nested "${invalidPattern}" but expected "${pattern.expectedStructure}". This causes "propertyValues[itemName] is not iterable" error in n8n.`, 169 | fix: this.generateFixMessage(pattern) 170 | }); 171 | 172 | // Generate autofix 173 | if (!result.autofix) { 174 | result.autofix = this.generateAutofix(config, pattern); 175 | } 176 | } 177 | } 178 | 179 | return result; 180 | } 181 | 182 | /** 183 | * Apply autofix to a configuration 184 | */ 185 | static applyAutofix( 186 | config: NodeConfig, 187 | pattern: FixedCollectionPattern 188 | ): NodeConfig | NodeConfigValue[] { 189 | const fixedConfig = this.generateAutofix(config, pattern); 190 | // For If/Filter nodes, the autofix might return just the values array 191 | if (pattern.nodeType === 'if' || pattern.nodeType === 'filter') { 192 | const conditions = config.conditions; 193 | if (conditions && typeof conditions === 'object' && !Array.isArray(conditions) && 'values' in conditions) { 194 | const values = conditions.values; 195 | if (values !== undefined && values !== null && 196 | (Array.isArray(values) || typeof values === 'object')) { 197 | return values as NodeConfig | NodeConfigValue[]; 198 | } 199 | } 200 | } 201 | return fixedConfig; 202 | } 203 | 204 | /** 205 | * Normalize node type to handle various formats 206 | */ 207 | private static normalizeNodeType(nodeType: string): string { 208 | return nodeType 209 | .replace('n8n-nodes-base.', '') 210 | .replace('nodes-base.', '') 211 | .replace('@n8n/n8n-nodes-langchain.', '') 212 | .toLowerCase(); 213 | } 214 | 215 | /** 216 | * Get pattern configuration for a specific node type 217 | */ 218 | private static getPatternForNode(nodeType: string): FixedCollectionPattern | undefined { 219 | return this.KNOWN_PATTERNS.find(p => p.nodeType === nodeType); 220 | } 221 | 222 | /** 223 | * Check if configuration has an invalid structure 224 | * Includes circular reference protection 225 | */ 226 | private static hasInvalidStructure( 227 | config: NodeConfig, 228 | pattern: string 229 | ): boolean { 230 | const parts = pattern.split('.'); 231 | let current: NodeConfigValue = config; 232 | const visited = new WeakSet<object>(); 233 | 234 | for (const part of parts) { 235 | // Check for null/undefined 236 | if (current === null || current === undefined) { 237 | return false; 238 | } 239 | 240 | // Check if it's an object (but not an array for property access) 241 | if (typeof current !== 'object' || Array.isArray(current)) { 242 | return false; 243 | } 244 | 245 | // Check for circular reference 246 | if (visited.has(current)) { 247 | return false; // Circular reference detected, invalid structure 248 | } 249 | visited.add(current); 250 | 251 | // Check if property exists (using hasOwnProperty to avoid prototype pollution) 252 | if (!Object.prototype.hasOwnProperty.call(current, part)) { 253 | return false; 254 | } 255 | 256 | const nextValue = (current as NodeConfig)[part]; 257 | if (typeof nextValue !== 'object' || nextValue === null) { 258 | // If we have more parts to traverse but current value is not an object, invalid structure 259 | if (parts.indexOf(part) < parts.length - 1) { 260 | return false; 261 | } 262 | } 263 | current = nextValue as NodeConfig; 264 | } 265 | 266 | return true; 267 | } 268 | 269 | /** 270 | * Generate a fix message for the specific pattern 271 | */ 272 | private static generateFixMessage(pattern: FixedCollectionPattern): string { 273 | switch (pattern.nodeType) { 274 | case 'switch': 275 | return 'Use: { "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }'; 276 | case 'if': 277 | case 'filter': 278 | return 'Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"'; 279 | case 'summarize': 280 | return 'Use: { "fieldsToSummarize": { "values": [...] } } not nested values.values'; 281 | case 'comparedatasets': 282 | return 'Use: { "mergeByFields": { "values": [...] } } not nested values.values'; 283 | case 'sort': 284 | return 'Use: { "sortFieldsUi": { "sortField": [...] } } not sortField.values'; 285 | case 'aggregate': 286 | return 'Use: { "fieldsToAggregate": { "fieldToAggregate": [...] } } not fieldToAggregate.values'; 287 | case 'set': 288 | return 'Use: { "fields": { "values": [...] } } not nested values.values'; 289 | case 'html': 290 | return 'Use: { "extractionValues": { "values": [...] } } not nested values.values'; 291 | case 'httprequest': 292 | return 'Use: { "body": { "parameters": [...] } } not parameters.values'; 293 | case 'airtable': 294 | return 'Use: { "sort": { "sortField": [...] } } not sortField.values'; 295 | default: 296 | return `Use ${pattern.expectedStructure} structure`; 297 | } 298 | } 299 | 300 | /** 301 | * Generate autofix for invalid structures 302 | */ 303 | private static generateAutofix( 304 | config: NodeConfig, 305 | pattern: FixedCollectionPattern 306 | ): NodeConfig | NodeConfigValue[] { 307 | const fixedConfig = { ...config }; 308 | 309 | switch (pattern.nodeType) { 310 | case 'switch': { 311 | const rules = config.rules; 312 | if (this.isNodeConfig(rules)) { 313 | const conditions = rules.conditions; 314 | if (this.isNodeConfig(conditions) && 'values' in conditions) { 315 | const values = conditions.values; 316 | fixedConfig.rules = { 317 | values: Array.isArray(values) 318 | ? values.map((condition, index) => ({ 319 | conditions: condition, 320 | outputKey: `output${index + 1}` 321 | })) 322 | : [{ 323 | conditions: values, 324 | outputKey: 'output1' 325 | }] 326 | }; 327 | } else if (conditions) { 328 | fixedConfig.rules = { 329 | values: [{ 330 | conditions: conditions, 331 | outputKey: 'output1' 332 | }] 333 | }; 334 | } 335 | } 336 | break; 337 | } 338 | 339 | case 'if': 340 | case 'filter': { 341 | const conditions = config.conditions; 342 | if (this.isNodeConfig(conditions) && 'values' in conditions) { 343 | const values = conditions.values; 344 | if (values !== undefined && values !== null && 345 | (Array.isArray(values) || typeof values === 'object')) { 346 | return values as NodeConfig | NodeConfigValue[]; 347 | } 348 | } 349 | break; 350 | } 351 | 352 | case 'summarize': { 353 | const fieldsToSummarize = config.fieldsToSummarize; 354 | if (this.isNodeConfig(fieldsToSummarize)) { 355 | const values = fieldsToSummarize.values; 356 | if (this.isNodeConfig(values) && 'values' in values) { 357 | fixedConfig.fieldsToSummarize = { 358 | values: values.values 359 | }; 360 | } 361 | } 362 | break; 363 | } 364 | 365 | case 'comparedatasets': { 366 | const mergeByFields = config.mergeByFields; 367 | if (this.isNodeConfig(mergeByFields)) { 368 | const values = mergeByFields.values; 369 | if (this.isNodeConfig(values) && 'values' in values) { 370 | fixedConfig.mergeByFields = { 371 | values: values.values 372 | }; 373 | } 374 | } 375 | break; 376 | } 377 | 378 | case 'sort': { 379 | const sortFieldsUi = config.sortFieldsUi; 380 | if (this.isNodeConfig(sortFieldsUi)) { 381 | const sortField = sortFieldsUi.sortField; 382 | if (this.isNodeConfig(sortField) && 'values' in sortField) { 383 | fixedConfig.sortFieldsUi = { 384 | sortField: sortField.values 385 | }; 386 | } 387 | } 388 | break; 389 | } 390 | 391 | case 'aggregate': { 392 | const fieldsToAggregate = config.fieldsToAggregate; 393 | if (this.isNodeConfig(fieldsToAggregate)) { 394 | const fieldToAggregate = fieldsToAggregate.fieldToAggregate; 395 | if (this.isNodeConfig(fieldToAggregate) && 'values' in fieldToAggregate) { 396 | fixedConfig.fieldsToAggregate = { 397 | fieldToAggregate: fieldToAggregate.values 398 | }; 399 | } 400 | } 401 | break; 402 | } 403 | 404 | case 'set': { 405 | const fields = config.fields; 406 | if (this.isNodeConfig(fields)) { 407 | const values = fields.values; 408 | if (this.isNodeConfig(values) && 'values' in values) { 409 | fixedConfig.fields = { 410 | values: values.values 411 | }; 412 | } 413 | } 414 | break; 415 | } 416 | 417 | case 'html': { 418 | const extractionValues = config.extractionValues; 419 | if (this.isNodeConfig(extractionValues)) { 420 | const values = extractionValues.values; 421 | if (this.isNodeConfig(values) && 'values' in values) { 422 | fixedConfig.extractionValues = { 423 | values: values.values 424 | }; 425 | } 426 | } 427 | break; 428 | } 429 | 430 | case 'httprequest': { 431 | const body = config.body; 432 | if (this.isNodeConfig(body)) { 433 | const parameters = body.parameters; 434 | if (this.isNodeConfig(parameters) && 'values' in parameters) { 435 | fixedConfig.body = { 436 | ...body, 437 | parameters: parameters.values 438 | }; 439 | } 440 | } 441 | break; 442 | } 443 | 444 | case 'airtable': { 445 | const sort = config.sort; 446 | if (this.isNodeConfig(sort)) { 447 | const sortField = sort.sortField; 448 | if (this.isNodeConfig(sortField) && 'values' in sortField) { 449 | fixedConfig.sort = { 450 | sortField: sortField.values 451 | }; 452 | } 453 | } 454 | break; 455 | } 456 | } 457 | 458 | return fixedConfig; 459 | } 460 | 461 | /** 462 | * Get all known patterns (for testing and documentation) 463 | * Returns a deep copy to prevent external modifications 464 | */ 465 | static getAllPatterns(): FixedCollectionPattern[] { 466 | return this.KNOWN_PATTERNS.map(pattern => ({ 467 | ...pattern, 468 | invalidPatterns: [...pattern.invalidPatterns] 469 | })); 470 | } 471 | 472 | /** 473 | * Check if a node type is susceptible to fixedCollection issues 474 | */ 475 | static isNodeSusceptible(nodeType: string): boolean { 476 | const normalizedType = this.normalizeNodeType(nodeType); 477 | return this.KNOWN_PATTERNS.some(p => p.nodeType === normalizedType); 478 | } 479 | } ```