This is page 18 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/monitoring/cache-metrics.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Unit tests for cache metrics monitoring functionality 3 | */ 4 | 5 | import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 6 | import { 7 | getInstanceCacheMetrics, 8 | getN8nApiClient, 9 | clearInstanceCache 10 | } from '../../../src/mcp/handlers-n8n-manager'; 11 | import { 12 | cacheMetrics, 13 | getCacheStatistics 14 | } from '../../../src/utils/cache-utils'; 15 | import { InstanceContext } from '../../../src/types/instance-context'; 16 | 17 | // Mock the N8nApiClient 18 | vi.mock('../../../src/clients/n8n-api-client', () => ({ 19 | N8nApiClient: vi.fn().mockImplementation((config) => ({ 20 | config, 21 | getWorkflows: vi.fn().mockResolvedValue([]), 22 | getWorkflow: vi.fn().mockResolvedValue({}), 23 | isConnected: vi.fn().mockReturnValue(true) 24 | })) 25 | })); 26 | 27 | // Mock logger to reduce noise in tests 28 | vi.mock('../../../src/utils/logger', () => { 29 | const mockLogger = { 30 | debug: vi.fn(), 31 | info: vi.fn(), 32 | warn: vi.fn(), 33 | error: vi.fn() 34 | }; 35 | 36 | return { 37 | Logger: vi.fn().mockImplementation(() => mockLogger), 38 | logger: mockLogger 39 | }; 40 | }); 41 | 42 | describe('Cache Metrics Monitoring', () => { 43 | beforeEach(() => { 44 | // Clear cache before each test 45 | clearInstanceCache(); 46 | cacheMetrics.reset(); 47 | 48 | // Reset environment variables 49 | delete process.env.N8N_API_URL; 50 | delete process.env.N8N_API_KEY; 51 | delete process.env.INSTANCE_CACHE_MAX; 52 | delete process.env.INSTANCE_CACHE_TTL_MINUTES; 53 | }); 54 | 55 | afterEach(() => { 56 | vi.clearAllMocks(); 57 | }); 58 | 59 | describe('getInstanceCacheStatistics', () => { 60 | it('should return initial statistics', () => { 61 | const stats = getInstanceCacheMetrics(); 62 | 63 | expect(stats).toBeDefined(); 64 | expect(stats.hits).toBe(0); 65 | expect(stats.misses).toBe(0); 66 | expect(stats.size).toBe(0); 67 | expect(stats.avgHitRate).toBe(0); 68 | }); 69 | 70 | it('should track cache hits and misses', () => { 71 | const context1: InstanceContext = { 72 | n8nApiUrl: 'https://api1.n8n.cloud', 73 | n8nApiKey: 'key1', 74 | instanceId: 'instance1' 75 | }; 76 | 77 | const context2: InstanceContext = { 78 | n8nApiUrl: 'https://api2.n8n.cloud', 79 | n8nApiKey: 'key2', 80 | instanceId: 'instance2' 81 | }; 82 | 83 | // First access - cache miss 84 | getN8nApiClient(context1); 85 | let stats = getInstanceCacheMetrics(); 86 | expect(stats.misses).toBe(1); 87 | expect(stats.hits).toBe(0); 88 | expect(stats.size).toBe(1); 89 | 90 | // Second access same context - cache hit 91 | getN8nApiClient(context1); 92 | stats = getInstanceCacheMetrics(); 93 | expect(stats.hits).toBe(1); 94 | expect(stats.misses).toBe(1); 95 | expect(stats.avgHitRate).toBe(0.5); // 1 hit / 2 total 96 | 97 | // Third access different context - cache miss 98 | getN8nApiClient(context2); 99 | stats = getInstanceCacheMetrics(); 100 | expect(stats.hits).toBe(1); 101 | expect(stats.misses).toBe(2); 102 | expect(stats.size).toBe(2); 103 | expect(stats.avgHitRate).toBeCloseTo(0.333, 2); // 1 hit / 3 total 104 | }); 105 | 106 | it('should track evictions when cache is full', () => { 107 | // Note: Cache is created with default size (100), so we need many items to trigger evictions 108 | // This test verifies that eviction tracking works, even if we don't hit the limit in practice 109 | const initialStats = getInstanceCacheMetrics(); 110 | 111 | // The cache dispose callback should track evictions when items are removed 112 | // For this test, we'll verify the eviction tracking mechanism exists 113 | expect(initialStats.evictions).toBeGreaterThanOrEqual(0); 114 | 115 | // Add a few items to cache 116 | const contexts = [ 117 | { n8nApiUrl: 'https://api1.n8n.cloud', n8nApiKey: 'key1' }, 118 | { n8nApiUrl: 'https://api2.n8n.cloud', n8nApiKey: 'key2' }, 119 | { n8nApiUrl: 'https://api3.n8n.cloud', n8nApiKey: 'key3' } 120 | ]; 121 | 122 | contexts.forEach(ctx => getN8nApiClient(ctx)); 123 | 124 | const stats = getInstanceCacheMetrics(); 125 | expect(stats.size).toBe(3); // All items should fit in default cache (max: 100) 126 | }); 127 | 128 | it('should track cache operations over time', () => { 129 | const context: InstanceContext = { 130 | n8nApiUrl: 'https://api.n8n.cloud', 131 | n8nApiKey: 'test-key' 132 | }; 133 | 134 | // Simulate multiple operations 135 | for (let i = 0; i < 10; i++) { 136 | getN8nApiClient(context); 137 | } 138 | 139 | const stats = getInstanceCacheMetrics(); 140 | expect(stats.hits).toBe(9); // First is miss, rest are hits 141 | expect(stats.misses).toBe(1); 142 | expect(stats.avgHitRate).toBe(0.9); // 9/10 143 | expect(stats.sets).toBeGreaterThanOrEqual(1); 144 | }); 145 | 146 | it('should include timestamp information', () => { 147 | const stats = getInstanceCacheMetrics(); 148 | 149 | expect(stats.createdAt).toBeInstanceOf(Date); 150 | expect(stats.lastResetAt).toBeInstanceOf(Date); 151 | expect(stats.createdAt.getTime()).toBeLessThanOrEqual(Date.now()); 152 | }); 153 | 154 | it('should track cache clear operations', () => { 155 | const context: InstanceContext = { 156 | n8nApiUrl: 'https://api.n8n.cloud', 157 | n8nApiKey: 'test-key' 158 | }; 159 | 160 | // Add some clients 161 | getN8nApiClient(context); 162 | 163 | // Clear cache 164 | clearInstanceCache(); 165 | 166 | const stats = getInstanceCacheMetrics(); 167 | expect(stats.clears).toBe(1); 168 | expect(stats.size).toBe(0); 169 | }); 170 | }); 171 | 172 | describe('Cache Metrics with Different Scenarios', () => { 173 | it('should handle rapid successive requests', () => { 174 | const context: InstanceContext = { 175 | n8nApiUrl: 'https://api.n8n.cloud', 176 | n8nApiKey: 'rapid-test' 177 | }; 178 | 179 | // Simulate rapid requests 180 | const promises = []; 181 | for (let i = 0; i < 50; i++) { 182 | promises.push(Promise.resolve(getN8nApiClient(context))); 183 | } 184 | 185 | return Promise.all(promises).then(() => { 186 | const stats = getInstanceCacheMetrics(); 187 | expect(stats.hits).toBe(49); // First is miss 188 | expect(stats.misses).toBe(1); 189 | expect(stats.avgHitRate).toBe(0.98); // 49/50 190 | }); 191 | }); 192 | 193 | it('should track metrics for fallback to environment variables', () => { 194 | // Note: Singleton mode (no context) doesn't use the instance cache 195 | // This test verifies that cache metrics are not affected by singleton usage 196 | const initialStats = getInstanceCacheMetrics(); 197 | 198 | process.env.N8N_API_URL = 'https://env.n8n.cloud'; 199 | process.env.N8N_API_KEY = 'env-key'; 200 | 201 | // Calls without context use singleton mode (no cache metrics) 202 | getN8nApiClient(); 203 | getN8nApiClient(); 204 | 205 | const stats = getInstanceCacheMetrics(); 206 | expect(stats.hits).toBe(initialStats.hits); 207 | expect(stats.misses).toBe(initialStats.misses); 208 | }); 209 | 210 | it('should maintain separate metrics for different instances', () => { 211 | const contexts = Array.from({ length: 5 }, (_, i) => ({ 212 | n8nApiUrl: `https://api${i}.n8n.cloud`, 213 | n8nApiKey: `key${i}`, 214 | instanceId: `instance${i}` 215 | })); 216 | 217 | // Access each instance twice 218 | contexts.forEach(ctx => { 219 | getN8nApiClient(ctx); // Miss 220 | getN8nApiClient(ctx); // Hit 221 | }); 222 | 223 | const stats = getInstanceCacheMetrics(); 224 | expect(stats.hits).toBe(5); 225 | expect(stats.misses).toBe(5); 226 | expect(stats.size).toBe(5); 227 | expect(stats.avgHitRate).toBe(0.5); 228 | }); 229 | 230 | it('should handle cache with TTL expiration', () => { 231 | // Note: TTL configuration is set when cache is created, not dynamically 232 | // This test verifies that TTL-related cache behavior can be tracked 233 | const context: InstanceContext = { 234 | n8nApiUrl: 'https://ttl-test.n8n.cloud', 235 | n8nApiKey: 'ttl-key' 236 | }; 237 | 238 | // First access - miss 239 | getN8nApiClient(context); 240 | 241 | // Second access - hit (within TTL) 242 | getN8nApiClient(context); 243 | 244 | const stats = getInstanceCacheMetrics(); 245 | expect(stats.hits).toBe(1); 246 | expect(stats.misses).toBe(1); 247 | }); 248 | }); 249 | 250 | describe('getCacheStatistics (formatted)', () => { 251 | it('should return human-readable statistics', () => { 252 | const context: InstanceContext = { 253 | n8nApiUrl: 'https://api.n8n.cloud', 254 | n8nApiKey: 'test-key' 255 | }; 256 | 257 | // Generate some activity 258 | getN8nApiClient(context); 259 | getN8nApiClient(context); 260 | getN8nApiClient({ ...context, instanceId: 'different' }); 261 | 262 | const formattedStats = getCacheStatistics(); 263 | 264 | expect(formattedStats).toContain('Cache Statistics:'); 265 | expect(formattedStats).toContain('Runtime:'); 266 | expect(formattedStats).toContain('Total Operations:'); 267 | expect(formattedStats).toContain('Hit Rate:'); 268 | expect(formattedStats).toContain('Current Size:'); 269 | expect(formattedStats).toContain('Total Evictions:'); 270 | }); 271 | 272 | it('should show runtime in minutes', () => { 273 | const stats = getCacheStatistics(); 274 | expect(stats).toMatch(/Runtime: \d+ minutes/); 275 | }); 276 | 277 | it('should show operation counts', () => { 278 | const context: InstanceContext = { 279 | n8nApiUrl: 'https://api.n8n.cloud', 280 | n8nApiKey: 'test-key' 281 | }; 282 | 283 | // Generate operations 284 | getN8nApiClient(context); // Set 285 | getN8nApiClient(context); // Hit 286 | clearInstanceCache(); // Clear 287 | 288 | const stats = getCacheStatistics(); 289 | expect(stats).toContain('Sets: 1'); 290 | expect(stats).toContain('Clears: 1'); 291 | }); 292 | }); 293 | 294 | describe('Monitoring Performance Impact', () => { 295 | it('should have minimal performance overhead', () => { 296 | const context: InstanceContext = { 297 | n8nApiUrl: 'https://perf-test.n8n.cloud', 298 | n8nApiKey: 'perf-key' 299 | }; 300 | 301 | const startTime = performance.now(); 302 | 303 | // Perform many operations 304 | for (let i = 0; i < 1000; i++) { 305 | getN8nApiClient(context); 306 | } 307 | 308 | const endTime = performance.now(); 309 | const totalTime = endTime - startTime; 310 | 311 | // Should complete quickly (< 100ms for 1000 operations) 312 | expect(totalTime).toBeLessThan(100); 313 | 314 | // Verify metrics were tracked 315 | const stats = getInstanceCacheMetrics(); 316 | expect(stats.hits).toBe(999); 317 | expect(stats.misses).toBe(1); 318 | }); 319 | 320 | it('should handle concurrent metric updates', async () => { 321 | const contexts = Array.from({ length: 10 }, (_, i) => ({ 322 | n8nApiUrl: `https://concurrent${i}.n8n.cloud`, 323 | n8nApiKey: `key${i}` 324 | })); 325 | 326 | // Concurrent requests 327 | const promises = contexts.map(ctx => 328 | Promise.resolve(getN8nApiClient(ctx)) 329 | ); 330 | 331 | await Promise.all(promises); 332 | 333 | const stats = getInstanceCacheMetrics(); 334 | expect(stats.misses).toBe(10); 335 | expect(stats.size).toBe(10); 336 | }); 337 | }); 338 | 339 | describe('Edge Cases and Error Conditions', () => { 340 | it('should handle metrics when cache operations fail', () => { 341 | const invalidContext = { 342 | n8nApiUrl: '', 343 | n8nApiKey: '' 344 | } as InstanceContext; 345 | 346 | // This should fail validation but metrics should still work 347 | const client = getN8nApiClient(invalidContext); 348 | expect(client).toBeNull(); 349 | 350 | // Metrics should not be affected by validation failures 351 | const stats = getInstanceCacheMetrics(); 352 | expect(stats).toBeDefined(); 353 | }); 354 | 355 | it('should maintain metrics integrity after reset', () => { 356 | const context: InstanceContext = { 357 | n8nApiUrl: 'https://reset-test.n8n.cloud', 358 | n8nApiKey: 'reset-key' 359 | }; 360 | 361 | // Generate some metrics 362 | getN8nApiClient(context); 363 | getN8nApiClient(context); 364 | 365 | // Reset metrics 366 | cacheMetrics.reset(); 367 | 368 | // New operations should start fresh 369 | getN8nApiClient(context); 370 | const stats = getInstanceCacheMetrics(); 371 | 372 | expect(stats.hits).toBe(1); // Cache still has item from before reset 373 | expect(stats.misses).toBe(0); 374 | expect(stats.lastResetAt.getTime()).toBeGreaterThan(stats.createdAt.getTime()); 375 | }); 376 | 377 | it('should handle maximum cache size correctly', () => { 378 | // Note: Cache uses default configuration (max: 100) since it's created at module load 379 | const contexts = Array.from({ length: 5 }, (_, i) => ({ 380 | n8nApiUrl: `https://max${i}.n8n.cloud`, 381 | n8nApiKey: `key${i}` 382 | })); 383 | 384 | // Add items within default cache size 385 | contexts.forEach(ctx => getN8nApiClient(ctx)); 386 | 387 | const stats = getInstanceCacheMetrics(); 388 | expect(stats.size).toBe(5); // Should fit in default cache 389 | expect(stats.maxSize).toBe(100); // Default max size 390 | }); 391 | }); 392 | }); ``` -------------------------------------------------------------------------------- /tests/unit/database/node-repository-core.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { NodeRepository } from '../../../src/database/node-repository'; 3 | import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter'; 4 | import { ParsedNode } from '../../../src/parsers/node-parser'; 5 | 6 | // Create a complete mock for DatabaseAdapter 7 | class MockDatabaseAdapter implements DatabaseAdapter { 8 | private statements = new Map<string, MockPreparedStatement>(); 9 | private mockData = new Map<string, any>(); 10 | 11 | prepare = vi.fn((sql: string) => { 12 | if (!this.statements.has(sql)) { 13 | this.statements.set(sql, new MockPreparedStatement(sql, this.mockData)); 14 | } 15 | return this.statements.get(sql)!; 16 | }); 17 | 18 | exec = vi.fn(); 19 | close = vi.fn(); 20 | pragma = vi.fn(); 21 | transaction = vi.fn((fn: () => any) => fn()); 22 | checkFTS5Support = vi.fn(() => true); 23 | inTransaction = false; 24 | 25 | // Test helper to set mock data 26 | _setMockData(key: string, value: any) { 27 | this.mockData.set(key, value); 28 | } 29 | 30 | // Test helper to get statement by SQL 31 | _getStatement(sql: string) { 32 | return this.statements.get(sql); 33 | } 34 | } 35 | 36 | class MockPreparedStatement implements PreparedStatement { 37 | run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 })); 38 | get = vi.fn(); 39 | all = vi.fn(() => []); 40 | iterate = vi.fn(); 41 | pluck = vi.fn(() => this); 42 | expand = vi.fn(() => this); 43 | raw = vi.fn(() => this); 44 | columns = vi.fn(() => []); 45 | bind = vi.fn(() => this); 46 | 47 | constructor(private sql: string, private mockData: Map<string, any>) { 48 | // Configure get() based on SQL pattern 49 | if (sql.includes('SELECT * FROM nodes WHERE node_type = ?')) { 50 | this.get = vi.fn((nodeType: string) => this.mockData.get(`node:${nodeType}`)); 51 | } 52 | 53 | // Configure all() for getAITools 54 | if (sql.includes('WHERE is_ai_tool = 1')) { 55 | this.all = vi.fn(() => this.mockData.get('ai_tools') || []); 56 | } 57 | } 58 | } 59 | 60 | describe('NodeRepository - Core Functionality', () => { 61 | let repository: NodeRepository; 62 | let mockAdapter: MockDatabaseAdapter; 63 | 64 | beforeEach(() => { 65 | mockAdapter = new MockDatabaseAdapter(); 66 | repository = new NodeRepository(mockAdapter); 67 | }); 68 | 69 | describe('saveNode', () => { 70 | it('should save a node with proper JSON serialization', () => { 71 | const parsedNode: ParsedNode = { 72 | nodeType: 'nodes-base.httpRequest', 73 | displayName: 'HTTP Request', 74 | description: 'Makes HTTP requests', 75 | category: 'transform', 76 | style: 'declarative', 77 | packageName: 'n8n-nodes-base', 78 | properties: [{ name: 'url', type: 'string' }], 79 | operations: [{ name: 'execute', displayName: 'Execute' }], 80 | credentials: [{ name: 'httpBasicAuth' }], 81 | isAITool: false, 82 | isTrigger: false, 83 | isWebhook: false, 84 | isVersioned: true, 85 | version: '1.0', 86 | documentation: 'HTTP Request documentation', 87 | outputs: undefined, 88 | outputNames: undefined 89 | }; 90 | 91 | repository.saveNode(parsedNode); 92 | 93 | // Verify prepare was called with correct SQL 94 | expect(mockAdapter.prepare).toHaveBeenCalledWith(expect.stringContaining('INSERT OR REPLACE INTO nodes')); 95 | 96 | // Get the prepared statement and verify run was called 97 | const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || ''); 98 | expect(stmt?.run).toHaveBeenCalledWith( 99 | 'nodes-base.httpRequest', 100 | 'n8n-nodes-base', 101 | 'HTTP Request', 102 | 'Makes HTTP requests', 103 | 'transform', 104 | 'declarative', 105 | 0, // isAITool 106 | 0, // isTrigger 107 | 0, // isWebhook 108 | 1, // isVersioned 109 | '1.0', 110 | 'HTTP Request documentation', 111 | JSON.stringify([{ name: 'url', type: 'string' }], null, 2), 112 | JSON.stringify([{ name: 'execute', displayName: 'Execute' }], null, 2), 113 | JSON.stringify([{ name: 'httpBasicAuth' }], null, 2), 114 | null, // outputs 115 | null // outputNames 116 | ); 117 | }); 118 | 119 | it('should handle nodes without optional fields', () => { 120 | const minimalNode: ParsedNode = { 121 | nodeType: 'nodes-base.simple', 122 | displayName: 'Simple Node', 123 | category: 'core', 124 | style: 'programmatic', 125 | packageName: 'n8n-nodes-base', 126 | properties: [], 127 | operations: [], 128 | credentials: [], 129 | isAITool: true, 130 | isTrigger: true, 131 | isWebhook: true, 132 | isVersioned: false, 133 | outputs: undefined, 134 | outputNames: undefined 135 | }; 136 | 137 | repository.saveNode(minimalNode); 138 | 139 | const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || ''); 140 | const runCall = stmt?.run.mock.lastCall; 141 | 142 | expect(runCall?.[2]).toBe('Simple Node'); // displayName 143 | expect(runCall?.[3]).toBeUndefined(); // description 144 | expect(runCall?.[10]).toBeUndefined(); // version 145 | expect(runCall?.[11]).toBeNull(); // documentation 146 | }); 147 | }); 148 | 149 | describe('getNode', () => { 150 | it('should retrieve and deserialize a node correctly', () => { 151 | const mockRow = { 152 | node_type: 'nodes-base.httpRequest', 153 | display_name: 'HTTP Request', 154 | description: 'Makes HTTP requests', 155 | category: 'transform', 156 | development_style: 'declarative', 157 | package_name: 'n8n-nodes-base', 158 | is_ai_tool: 0, 159 | is_trigger: 0, 160 | is_webhook: 0, 161 | is_versioned: 1, 162 | version: '1.0', 163 | properties_schema: JSON.stringify([{ name: 'url', type: 'string' }]), 164 | operations: JSON.stringify([{ name: 'execute' }]), 165 | credentials_required: JSON.stringify([{ name: 'httpBasicAuth' }]), 166 | documentation: 'HTTP docs', 167 | outputs: null, 168 | output_names: null 169 | }; 170 | 171 | mockAdapter._setMockData('node:nodes-base.httpRequest', mockRow); 172 | 173 | const result = repository.getNode('nodes-base.httpRequest'); 174 | 175 | expect(result).toEqual({ 176 | nodeType: 'nodes-base.httpRequest', 177 | displayName: 'HTTP Request', 178 | description: 'Makes HTTP requests', 179 | category: 'transform', 180 | developmentStyle: 'declarative', 181 | package: 'n8n-nodes-base', 182 | isAITool: false, 183 | isTrigger: false, 184 | isWebhook: false, 185 | isVersioned: true, 186 | version: '1.0', 187 | properties: [{ name: 'url', type: 'string' }], 188 | operations: [{ name: 'execute' }], 189 | credentials: [{ name: 'httpBasicAuth' }], 190 | hasDocumentation: true, 191 | outputs: null, 192 | outputNames: null 193 | }); 194 | }); 195 | 196 | it('should return null for non-existent nodes', () => { 197 | const result = repository.getNode('non-existent'); 198 | expect(result).toBeNull(); 199 | }); 200 | 201 | it('should handle invalid JSON gracefully', () => { 202 | const mockRow = { 203 | node_type: 'nodes-base.broken', 204 | display_name: 'Broken Node', 205 | description: 'Node with broken JSON', 206 | category: 'transform', 207 | development_style: 'declarative', 208 | package_name: 'n8n-nodes-base', 209 | is_ai_tool: 0, 210 | is_trigger: 0, 211 | is_webhook: 0, 212 | is_versioned: 0, 213 | version: null, 214 | properties_schema: '{invalid json', 215 | operations: 'not json at all', 216 | credentials_required: '{"valid": "json"}', 217 | documentation: null, 218 | outputs: null, 219 | output_names: null 220 | }; 221 | 222 | mockAdapter._setMockData('node:nodes-base.broken', mockRow); 223 | 224 | const result = repository.getNode('nodes-base.broken'); 225 | 226 | expect(result?.properties).toEqual([]); // defaultValue from safeJsonParse 227 | expect(result?.operations).toEqual([]); // defaultValue from safeJsonParse 228 | expect(result?.credentials).toEqual({ valid: 'json' }); // successfully parsed 229 | }); 230 | }); 231 | 232 | describe('getAITools', () => { 233 | it('should retrieve all AI tools sorted by display name', () => { 234 | const mockAITools = [ 235 | { 236 | node_type: 'nodes-base.openai', 237 | display_name: 'OpenAI', 238 | description: 'OpenAI integration', 239 | package_name: 'n8n-nodes-base' 240 | }, 241 | { 242 | node_type: 'nodes-base.agent', 243 | display_name: 'AI Agent', 244 | description: 'AI Agent node', 245 | package_name: '@n8n/n8n-nodes-langchain' 246 | } 247 | ]; 248 | 249 | mockAdapter._setMockData('ai_tools', mockAITools); 250 | 251 | const result = repository.getAITools(); 252 | 253 | expect(result).toEqual([ 254 | { 255 | nodeType: 'nodes-base.openai', 256 | displayName: 'OpenAI', 257 | description: 'OpenAI integration', 258 | package: 'n8n-nodes-base' 259 | }, 260 | { 261 | nodeType: 'nodes-base.agent', 262 | displayName: 'AI Agent', 263 | description: 'AI Agent node', 264 | package: '@n8n/n8n-nodes-langchain' 265 | } 266 | ]); 267 | }); 268 | 269 | it('should return empty array when no AI tools exist', () => { 270 | mockAdapter._setMockData('ai_tools', []); 271 | 272 | const result = repository.getAITools(); 273 | 274 | expect(result).toEqual([]); 275 | }); 276 | }); 277 | 278 | describe('safeJsonParse', () => { 279 | it('should parse valid JSON', () => { 280 | // Access private method through the class 281 | const parseMethod = (repository as any).safeJsonParse.bind(repository); 282 | 283 | const validJson = '{"key": "value", "number": 42}'; 284 | const result = parseMethod(validJson, {}); 285 | 286 | expect(result).toEqual({ key: 'value', number: 42 }); 287 | }); 288 | 289 | it('should return default value for invalid JSON', () => { 290 | const parseMethod = (repository as any).safeJsonParse.bind(repository); 291 | 292 | const invalidJson = '{invalid json}'; 293 | const defaultValue = { default: true }; 294 | const result = parseMethod(invalidJson, defaultValue); 295 | 296 | expect(result).toEqual(defaultValue); 297 | }); 298 | 299 | it('should handle empty strings', () => { 300 | const parseMethod = (repository as any).safeJsonParse.bind(repository); 301 | 302 | const result = parseMethod('', []); 303 | expect(result).toEqual([]); 304 | }); 305 | 306 | it('should handle null and undefined', () => { 307 | const parseMethod = (repository as any).safeJsonParse.bind(repository); 308 | 309 | // JSON.parse(null) returns null, not an error 310 | expect(parseMethod(null, 'default')).toBe(null); 311 | expect(parseMethod(undefined, 'default')).toBe('default'); 312 | }); 313 | }); 314 | 315 | describe('Edge Cases', () => { 316 | it('should handle very large JSON properties', () => { 317 | const largeProperties = Array(1000).fill(null).map((_, i) => ({ 318 | name: `prop${i}`, 319 | type: 'string', 320 | description: 'A'.repeat(100) 321 | })); 322 | 323 | const node: ParsedNode = { 324 | nodeType: 'nodes-base.large', 325 | displayName: 'Large Node', 326 | category: 'test', 327 | style: 'declarative', 328 | packageName: 'test', 329 | properties: largeProperties, 330 | operations: [], 331 | credentials: [], 332 | isAITool: false, 333 | isTrigger: false, 334 | isWebhook: false, 335 | isVersioned: false, 336 | outputs: undefined, 337 | outputNames: undefined 338 | }; 339 | 340 | repository.saveNode(node); 341 | 342 | const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || ''); 343 | const runCall = stmt?.run.mock.lastCall; 344 | const savedProperties = runCall?.[12]; 345 | 346 | expect(savedProperties).toBe(JSON.stringify(largeProperties, null, 2)); 347 | }); 348 | 349 | it('should handle boolean conversion for integer fields', () => { 350 | const mockRow = { 351 | node_type: 'nodes-base.bool-test', 352 | display_name: 'Bool Test', 353 | description: 'Testing boolean conversion', 354 | category: 'test', 355 | development_style: 'declarative', 356 | package_name: 'test', 357 | is_ai_tool: 1, 358 | is_trigger: 0, 359 | is_webhook: '1', // String that should be converted 360 | is_versioned: '0', // String that should be converted 361 | version: null, 362 | properties_schema: '[]', 363 | operations: '[]', 364 | credentials_required: '[]', 365 | documentation: null, 366 | outputs: null, 367 | output_names: null 368 | }; 369 | 370 | mockAdapter._setMockData('node:nodes-base.bool-test', mockRow); 371 | 372 | const result = repository.getNode('nodes-base.bool-test'); 373 | 374 | expect(result?.isAITool).toBe(true); 375 | expect(result?.isTrigger).toBe(false); 376 | expect(result?.isWebhook).toBe(true); 377 | expect(result?.isVersioned).toBe(false); 378 | }); 379 | }); 380 | }); ``` -------------------------------------------------------------------------------- /tests/integration/ai-validation/ai-tool-validation.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration Tests: AI Tool Validation 3 | * 4 | * Tests AI tool node validation against real n8n instance. 5 | * Covers HTTP Request Tool, Code Tool, Vector Store Tool, Workflow Tool, Calculator Tool. 6 | */ 7 | 8 | import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; 9 | import { createTestContext, TestContext, createTestWorkflowName } from '../n8n-api/utils/test-context'; 10 | import { getTestN8nClient } from '../n8n-api/utils/n8n-client'; 11 | import { N8nApiClient } from '../../../src/services/n8n-api-client'; 12 | import { cleanupOrphanedWorkflows } from '../n8n-api/utils/cleanup-helpers'; 13 | import { createMcpContext } from '../n8n-api/utils/mcp-context'; 14 | import { InstanceContext } from '../../../src/types/instance-context'; 15 | import { handleValidateWorkflow } from '../../../src/mcp/handlers-n8n-manager'; 16 | import { getNodeRepository, closeNodeRepository } from '../n8n-api/utils/node-repository'; 17 | import { NodeRepository } from '../../../src/database/node-repository'; 18 | import { ValidationResponse } from '../n8n-api/types/mcp-responses'; 19 | import { 20 | createHTTPRequestToolNode, 21 | createCodeToolNode, 22 | createVectorStoreToolNode, 23 | createWorkflowToolNode, 24 | createCalculatorToolNode, 25 | createAIWorkflow 26 | } from './helpers'; 27 | 28 | describe('Integration: AI Tool Validation', () => { 29 | let context: TestContext; 30 | let client: N8nApiClient; 31 | let mcpContext: InstanceContext; 32 | let repository: NodeRepository; 33 | 34 | beforeEach(async () => { 35 | context = createTestContext(); 36 | client = getTestN8nClient(); 37 | mcpContext = createMcpContext(); 38 | repository = await getNodeRepository(); 39 | }); 40 | 41 | afterEach(async () => { 42 | await context.cleanup(); 43 | }); 44 | 45 | afterAll(async () => { 46 | await closeNodeRepository(); 47 | if (!process.env.CI) { 48 | await cleanupOrphanedWorkflows(); 49 | } 50 | }); 51 | 52 | // ====================================================================== 53 | // HTTP Request Tool Tests 54 | // ====================================================================== 55 | 56 | describe('HTTP Request Tool', () => { 57 | it('should detect missing toolDescription', async () => { 58 | const httpTool = createHTTPRequestToolNode({ 59 | name: 'HTTP Request Tool', 60 | toolDescription: '', // Missing 61 | url: 'https://api.example.com/data', 62 | method: 'GET' 63 | }); 64 | 65 | const workflow = createAIWorkflow( 66 | [httpTool], 67 | {}, 68 | { 69 | name: createTestWorkflowName('HTTP Tool - No Description'), 70 | tags: ['mcp-integration-test', 'ai-validation'] 71 | } 72 | ); 73 | 74 | const created = await client.createWorkflow(workflow); 75 | context.trackWorkflow(created.id!); 76 | 77 | const response = await handleValidateWorkflow( 78 | { id: created.id }, 79 | repository, 80 | mcpContext 81 | ); 82 | 83 | expect(response.success).toBe(true); 84 | const data = response.data as ValidationResponse; 85 | 86 | expect(data.valid).toBe(false); 87 | expect(data.errors).toBeDefined(); 88 | 89 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 90 | expect(errorCodes).toContain('MISSING_TOOL_DESCRIPTION'); 91 | }); 92 | 93 | it('should detect missing URL', async () => { 94 | const httpTool = createHTTPRequestToolNode({ 95 | name: 'HTTP Request Tool', 96 | toolDescription: 'Fetches data from API', 97 | url: '', // Missing 98 | method: 'GET' 99 | }); 100 | 101 | const workflow = createAIWorkflow( 102 | [httpTool], 103 | {}, 104 | { 105 | name: createTestWorkflowName('HTTP Tool - No URL'), 106 | tags: ['mcp-integration-test', 'ai-validation'] 107 | } 108 | ); 109 | 110 | const created = await client.createWorkflow(workflow); 111 | context.trackWorkflow(created.id!); 112 | 113 | const response = await handleValidateWorkflow( 114 | { id: created.id }, 115 | repository, 116 | mcpContext 117 | ); 118 | 119 | expect(response.success).toBe(true); 120 | const data = response.data as ValidationResponse; 121 | 122 | expect(data.valid).toBe(false); 123 | expect(data.errors).toBeDefined(); 124 | 125 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 126 | expect(errorCodes).toContain('MISSING_URL'); 127 | }); 128 | 129 | it('should validate valid HTTP Request Tool', async () => { 130 | const httpTool = createHTTPRequestToolNode({ 131 | name: 'HTTP Request Tool', 132 | toolDescription: 'Fetches weather data from the weather API', 133 | url: 'https://api.weather.com/current', 134 | method: 'GET' 135 | }); 136 | 137 | const workflow = createAIWorkflow( 138 | [httpTool], 139 | {}, 140 | { 141 | name: createTestWorkflowName('HTTP Tool - Valid'), 142 | tags: ['mcp-integration-test', 'ai-validation'] 143 | } 144 | ); 145 | 146 | const created = await client.createWorkflow(workflow); 147 | context.trackWorkflow(created.id!); 148 | 149 | const response = await handleValidateWorkflow( 150 | { id: created.id }, 151 | repository, 152 | mcpContext 153 | ); 154 | 155 | expect(response.success).toBe(true); 156 | const data = response.data as ValidationResponse; 157 | 158 | expect(data.valid).toBe(true); 159 | expect(data.errors).toBeUndefined(); 160 | }); 161 | }); 162 | 163 | // ====================================================================== 164 | // Code Tool Tests 165 | // ====================================================================== 166 | 167 | describe('Code Tool', () => { 168 | it('should detect missing code', async () => { 169 | const codeTool = createCodeToolNode({ 170 | name: 'Code Tool', 171 | toolDescription: 'Processes data with custom logic', 172 | code: '' // Missing 173 | }); 174 | 175 | const workflow = createAIWorkflow( 176 | [codeTool], 177 | {}, 178 | { 179 | name: createTestWorkflowName('Code Tool - No Code'), 180 | tags: ['mcp-integration-test', 'ai-validation'] 181 | } 182 | ); 183 | 184 | const created = await client.createWorkflow(workflow); 185 | context.trackWorkflow(created.id!); 186 | 187 | const response = await handleValidateWorkflow( 188 | { id: created.id }, 189 | repository, 190 | mcpContext 191 | ); 192 | 193 | expect(response.success).toBe(true); 194 | const data = response.data as ValidationResponse; 195 | 196 | expect(data.valid).toBe(false); 197 | expect(data.errors).toBeDefined(); 198 | 199 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 200 | expect(errorCodes).toContain('MISSING_CODE'); 201 | }); 202 | 203 | it('should validate valid Code Tool', async () => { 204 | const codeTool = createCodeToolNode({ 205 | name: 'Code Tool', 206 | toolDescription: 'Calculates the sum of two numbers', 207 | code: 'return { sum: Number(a) + Number(b) };' 208 | }); 209 | 210 | const workflow = createAIWorkflow( 211 | [codeTool], 212 | {}, 213 | { 214 | name: createTestWorkflowName('Code Tool - Valid'), 215 | tags: ['mcp-integration-test', 'ai-validation'] 216 | } 217 | ); 218 | 219 | const created = await client.createWorkflow(workflow); 220 | context.trackWorkflow(created.id!); 221 | 222 | const response = await handleValidateWorkflow( 223 | { id: created.id }, 224 | repository, 225 | mcpContext 226 | ); 227 | 228 | expect(response.success).toBe(true); 229 | const data = response.data as ValidationResponse; 230 | 231 | expect(data.valid).toBe(true); 232 | expect(data.errors).toBeUndefined(); 233 | }); 234 | }); 235 | 236 | // ====================================================================== 237 | // Vector Store Tool Tests 238 | // ====================================================================== 239 | 240 | describe('Vector Store Tool', () => { 241 | it('should detect missing toolDescription', async () => { 242 | const vectorTool = createVectorStoreToolNode({ 243 | name: 'Vector Store Tool', 244 | toolDescription: '' // Missing 245 | }); 246 | 247 | const workflow = createAIWorkflow( 248 | [vectorTool], 249 | {}, 250 | { 251 | name: createTestWorkflowName('Vector Tool - No Description'), 252 | tags: ['mcp-integration-test', 'ai-validation'] 253 | } 254 | ); 255 | 256 | const created = await client.createWorkflow(workflow); 257 | context.trackWorkflow(created.id!); 258 | 259 | const response = await handleValidateWorkflow( 260 | { id: created.id }, 261 | repository, 262 | mcpContext 263 | ); 264 | 265 | expect(response.success).toBe(true); 266 | const data = response.data as ValidationResponse; 267 | 268 | expect(data.valid).toBe(false); 269 | expect(data.errors).toBeDefined(); 270 | 271 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 272 | expect(errorCodes).toContain('MISSING_TOOL_DESCRIPTION'); 273 | }); 274 | 275 | it('should validate valid Vector Store Tool', async () => { 276 | const vectorTool = createVectorStoreToolNode({ 277 | name: 'Vector Store Tool', 278 | toolDescription: 'Searches documentation in vector database' 279 | }); 280 | 281 | const workflow = createAIWorkflow( 282 | [vectorTool], 283 | {}, 284 | { 285 | name: createTestWorkflowName('Vector Tool - Valid'), 286 | tags: ['mcp-integration-test', 'ai-validation'] 287 | } 288 | ); 289 | 290 | const created = await client.createWorkflow(workflow); 291 | context.trackWorkflow(created.id!); 292 | 293 | const response = await handleValidateWorkflow( 294 | { id: created.id }, 295 | repository, 296 | mcpContext 297 | ); 298 | 299 | expect(response.success).toBe(true); 300 | const data = response.data as ValidationResponse; 301 | 302 | expect(data.valid).toBe(true); 303 | expect(data.errors).toBeUndefined(); 304 | }); 305 | }); 306 | 307 | // ====================================================================== 308 | // Workflow Tool Tests 309 | // ====================================================================== 310 | 311 | describe('Workflow Tool', () => { 312 | it('should detect missing workflowId', async () => { 313 | const workflowTool = createWorkflowToolNode({ 314 | name: 'Workflow Tool', 315 | toolDescription: 'Executes a sub-workflow', 316 | workflowId: '' // Missing 317 | }); 318 | 319 | const workflow = createAIWorkflow( 320 | [workflowTool], 321 | {}, 322 | { 323 | name: createTestWorkflowName('Workflow Tool - No ID'), 324 | tags: ['mcp-integration-test', 'ai-validation'] 325 | } 326 | ); 327 | 328 | const created = await client.createWorkflow(workflow); 329 | context.trackWorkflow(created.id!); 330 | 331 | const response = await handleValidateWorkflow( 332 | { id: created.id }, 333 | repository, 334 | mcpContext 335 | ); 336 | 337 | expect(response.success).toBe(true); 338 | const data = response.data as ValidationResponse; 339 | 340 | expect(data.valid).toBe(false); 341 | expect(data.errors).toBeDefined(); 342 | 343 | const errorCodes = data.errors!.map(e => e.details?.code || e.code); 344 | expect(errorCodes).toContain('MISSING_WORKFLOW_ID'); 345 | }); 346 | 347 | it('should validate valid Workflow Tool', async () => { 348 | const workflowTool = createWorkflowToolNode({ 349 | name: 'Workflow Tool', 350 | toolDescription: 'Processes customer data through validation workflow', 351 | workflowId: '123' 352 | }); 353 | 354 | const workflow = createAIWorkflow( 355 | [workflowTool], 356 | {}, 357 | { 358 | name: createTestWorkflowName('Workflow Tool - Valid'), 359 | tags: ['mcp-integration-test', 'ai-validation'] 360 | } 361 | ); 362 | 363 | const created = await client.createWorkflow(workflow); 364 | context.trackWorkflow(created.id!); 365 | 366 | const response = await handleValidateWorkflow( 367 | { id: created.id }, 368 | repository, 369 | mcpContext 370 | ); 371 | 372 | expect(response.success).toBe(true); 373 | const data = response.data as ValidationResponse; 374 | 375 | expect(data.valid).toBe(true); 376 | expect(data.errors).toBeUndefined(); 377 | }); 378 | }); 379 | 380 | // ====================================================================== 381 | // Calculator Tool Tests 382 | // ====================================================================== 383 | 384 | describe('Calculator Tool', () => { 385 | it('should validate Calculator Tool (no configuration needed)', async () => { 386 | const calcTool = createCalculatorToolNode({ 387 | name: 'Calculator' 388 | }); 389 | 390 | const workflow = createAIWorkflow( 391 | [calcTool], 392 | {}, 393 | { 394 | name: createTestWorkflowName('Calculator Tool - Valid'), 395 | tags: ['mcp-integration-test', 'ai-validation'] 396 | } 397 | ); 398 | 399 | const created = await client.createWorkflow(workflow); 400 | context.trackWorkflow(created.id!); 401 | 402 | const response = await handleValidateWorkflow( 403 | { id: created.id }, 404 | repository, 405 | mcpContext 406 | ); 407 | 408 | expect(response.success).toBe(true); 409 | const data = response.data as ValidationResponse; 410 | 411 | // Calculator has no required configuration 412 | expect(data.valid).toBe(true); 413 | expect(data.errors).toBeUndefined(); 414 | }); 415 | }); 416 | }); 417 | ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-auto-fixer.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { WorkflowAutoFixer, isNodeFormatIssue } from '@/services/workflow-auto-fixer'; 3 | import { NodeRepository } from '@/database/node-repository'; 4 | import type { WorkflowValidationResult } from '@/services/workflow-validator'; 5 | import type { ExpressionFormatIssue } from '@/services/expression-format-validator'; 6 | import type { Workflow, WorkflowNode } from '@/types/n8n-api'; 7 | 8 | vi.mock('@/database/node-repository'); 9 | vi.mock('@/services/node-similarity-service'); 10 | 11 | describe('WorkflowAutoFixer', () => { 12 | let autoFixer: WorkflowAutoFixer; 13 | let mockRepository: NodeRepository; 14 | 15 | const createMockWorkflow = (nodes: WorkflowNode[]): Workflow => ({ 16 | id: 'test-workflow', 17 | name: 'Test Workflow', 18 | active: false, 19 | nodes, 20 | connections: {}, 21 | settings: {}, 22 | createdAt: '', 23 | updatedAt: '' 24 | }); 25 | 26 | const createMockNode = (id: string, type: string, parameters: any = {}): WorkflowNode => ({ 27 | id, 28 | name: id, 29 | type, 30 | typeVersion: 1, 31 | position: [0, 0], 32 | parameters 33 | }); 34 | 35 | beforeEach(() => { 36 | vi.clearAllMocks(); 37 | mockRepository = new NodeRepository({} as any); 38 | autoFixer = new WorkflowAutoFixer(mockRepository); 39 | }); 40 | 41 | describe('Type Guards', () => { 42 | it('should identify NodeFormatIssue correctly', () => { 43 | const validIssue: ExpressionFormatIssue = { 44 | fieldPath: 'url', 45 | currentValue: '{{ $json.url }}', 46 | correctedValue: '={{ $json.url }}', 47 | issueType: 'missing-prefix', 48 | severity: 'error', 49 | explanation: 'Missing = prefix' 50 | } as any; 51 | (validIssue as any).nodeName = 'httpRequest'; 52 | (validIssue as any).nodeId = 'node-1'; 53 | 54 | const invalidIssue: ExpressionFormatIssue = { 55 | fieldPath: 'url', 56 | currentValue: '{{ $json.url }}', 57 | correctedValue: '={{ $json.url }}', 58 | issueType: 'missing-prefix', 59 | severity: 'error', 60 | explanation: 'Missing = prefix' 61 | }; 62 | 63 | expect(isNodeFormatIssue(validIssue)).toBe(true); 64 | expect(isNodeFormatIssue(invalidIssue)).toBe(false); 65 | }); 66 | }); 67 | 68 | describe('Expression Format Fixes', () => { 69 | it('should fix missing prefix in expressions', () => { 70 | const workflow = createMockWorkflow([ 71 | createMockNode('node-1', 'nodes-base.httpRequest', { 72 | url: '{{ $json.url }}', 73 | method: 'GET' 74 | }) 75 | ]); 76 | 77 | const formatIssues: ExpressionFormatIssue[] = [{ 78 | fieldPath: 'url', 79 | currentValue: '{{ $json.url }}', 80 | correctedValue: '={{ $json.url }}', 81 | issueType: 'missing-prefix', 82 | severity: 'error', 83 | explanation: 'Expression must start with =', 84 | nodeName: 'node-1', 85 | nodeId: 'node-1' 86 | } as any]; 87 | 88 | const validationResult: WorkflowValidationResult = { 89 | valid: false, 90 | errors: [], 91 | warnings: [], 92 | statistics: { 93 | totalNodes: 1, 94 | enabledNodes: 1, 95 | triggerNodes: 0, 96 | validConnections: 0, 97 | invalidConnections: 0, 98 | expressionsValidated: 0 99 | }, 100 | suggestions: [] 101 | }; 102 | 103 | const result = autoFixer.generateFixes(workflow, validationResult, formatIssues); 104 | 105 | expect(result.fixes).toHaveLength(1); 106 | expect(result.fixes[0].type).toBe('expression-format'); 107 | expect(result.fixes[0].before).toBe('{{ $json.url }}'); 108 | expect(result.fixes[0].after).toBe('={{ $json.url }}'); 109 | expect(result.fixes[0].confidence).toBe('high'); 110 | 111 | expect(result.operations).toHaveLength(1); 112 | expect(result.operations[0].type).toBe('updateNode'); 113 | }); 114 | 115 | it('should handle multiple expression fixes in same node', () => { 116 | const workflow = createMockWorkflow([ 117 | createMockNode('node-1', 'nodes-base.httpRequest', { 118 | url: '{{ $json.url }}', 119 | body: '{{ $json.body }}' 120 | }) 121 | ]); 122 | 123 | const formatIssues: ExpressionFormatIssue[] = [ 124 | { 125 | fieldPath: 'url', 126 | currentValue: '{{ $json.url }}', 127 | correctedValue: '={{ $json.url }}', 128 | issueType: 'missing-prefix', 129 | severity: 'error', 130 | explanation: 'Expression must start with =', 131 | nodeName: 'node-1', 132 | nodeId: 'node-1' 133 | } as any, 134 | { 135 | fieldPath: 'body', 136 | currentValue: '{{ $json.body }}', 137 | correctedValue: '={{ $json.body }}', 138 | issueType: 'missing-prefix', 139 | severity: 'error', 140 | explanation: 'Expression must start with =', 141 | nodeName: 'node-1', 142 | nodeId: 'node-1' 143 | } as any 144 | ]; 145 | 146 | const validationResult: WorkflowValidationResult = { 147 | valid: false, 148 | errors: [], 149 | warnings: [], 150 | statistics: { 151 | totalNodes: 1, 152 | enabledNodes: 1, 153 | triggerNodes: 0, 154 | validConnections: 0, 155 | invalidConnections: 0, 156 | expressionsValidated: 0 157 | }, 158 | suggestions: [] 159 | }; 160 | 161 | const result = autoFixer.generateFixes(workflow, validationResult, formatIssues); 162 | 163 | expect(result.fixes).toHaveLength(2); 164 | expect(result.operations).toHaveLength(1); // Single update operation for the node 165 | }); 166 | }); 167 | 168 | describe('TypeVersion Fixes', () => { 169 | it('should fix typeVersion exceeding maximum', () => { 170 | const workflow = createMockWorkflow([ 171 | createMockNode('node-1', 'nodes-base.httpRequest', {}) 172 | ]); 173 | 174 | const validationResult: WorkflowValidationResult = { 175 | valid: false, 176 | errors: [{ 177 | type: 'error', 178 | nodeId: 'node-1', 179 | nodeName: 'node-1', 180 | message: 'typeVersion 3.5 exceeds maximum supported version 2.0' 181 | }], 182 | warnings: [], 183 | statistics: { 184 | totalNodes: 1, 185 | enabledNodes: 1, 186 | triggerNodes: 0, 187 | validConnections: 0, 188 | invalidConnections: 0, 189 | expressionsValidated: 0 190 | }, 191 | suggestions: [] 192 | }; 193 | 194 | const result = autoFixer.generateFixes(workflow, validationResult, []); 195 | 196 | expect(result.fixes).toHaveLength(1); 197 | expect(result.fixes[0].type).toBe('typeversion-correction'); 198 | expect(result.fixes[0].before).toBe(3.5); 199 | expect(result.fixes[0].after).toBe(2); 200 | expect(result.fixes[0].confidence).toBe('medium'); 201 | }); 202 | }); 203 | 204 | describe('Error Output Configuration Fixes', () => { 205 | it('should remove conflicting onError setting', () => { 206 | const workflow = createMockWorkflow([ 207 | createMockNode('node-1', 'nodes-base.httpRequest', {}) 208 | ]); 209 | workflow.nodes[0].onError = 'continueErrorOutput'; 210 | 211 | const validationResult: WorkflowValidationResult = { 212 | valid: false, 213 | errors: [{ 214 | type: 'error', 215 | nodeId: 'node-1', 216 | nodeName: 'node-1', 217 | message: "Node has onError: 'continueErrorOutput' but no error output connections" 218 | }], 219 | warnings: [], 220 | statistics: { 221 | totalNodes: 1, 222 | enabledNodes: 1, 223 | triggerNodes: 0, 224 | validConnections: 0, 225 | invalidConnections: 0, 226 | expressionsValidated: 0 227 | }, 228 | suggestions: [] 229 | }; 230 | 231 | const result = autoFixer.generateFixes(workflow, validationResult, []); 232 | 233 | expect(result.fixes).toHaveLength(1); 234 | expect(result.fixes[0].type).toBe('error-output-config'); 235 | expect(result.fixes[0].before).toBe('continueErrorOutput'); 236 | expect(result.fixes[0].after).toBeUndefined(); 237 | expect(result.fixes[0].confidence).toBe('medium'); 238 | }); 239 | }); 240 | 241 | describe('setNestedValue Validation', () => { 242 | it('should throw error for non-object target', () => { 243 | expect(() => { 244 | autoFixer['setNestedValue'](null, ['field'], 'value'); 245 | }).toThrow('Cannot set value on non-object'); 246 | 247 | expect(() => { 248 | autoFixer['setNestedValue']('string', ['field'], 'value'); 249 | }).toThrow('Cannot set value on non-object'); 250 | }); 251 | 252 | it('should throw error for empty path', () => { 253 | expect(() => { 254 | autoFixer['setNestedValue']({}, [], 'value'); 255 | }).toThrow('Cannot set value with empty path'); 256 | }); 257 | 258 | it('should handle nested paths correctly', () => { 259 | const obj = { level1: { level2: { level3: 'old' } } }; 260 | autoFixer['setNestedValue'](obj, ['level1', 'level2', 'level3'], 'new'); 261 | expect(obj.level1.level2.level3).toBe('new'); 262 | }); 263 | 264 | it('should create missing nested objects', () => { 265 | const obj = {}; 266 | autoFixer['setNestedValue'](obj, ['level1', 'level2', 'level3'], 'value'); 267 | expect(obj).toEqual({ 268 | level1: { 269 | level2: { 270 | level3: 'value' 271 | } 272 | } 273 | }); 274 | }); 275 | 276 | it('should handle array indices in paths', () => { 277 | const obj: any = { items: [] }; 278 | autoFixer['setNestedValue'](obj, ['items[0]', 'name'], 'test'); 279 | expect(obj.items[0].name).toBe('test'); 280 | }); 281 | 282 | it('should throw error for invalid array notation', () => { 283 | const obj = {}; 284 | expect(() => { 285 | autoFixer['setNestedValue'](obj, ['field[abc]'], 'value'); 286 | }).toThrow('Invalid array notation: field[abc]'); 287 | }); 288 | 289 | it('should throw when trying to traverse non-object', () => { 290 | const obj = { field: 'string' }; 291 | expect(() => { 292 | autoFixer['setNestedValue'](obj, ['field', 'nested'], 'value'); 293 | }).toThrow('Cannot traverse through string at field'); 294 | }); 295 | }); 296 | 297 | describe('Confidence Filtering', () => { 298 | it('should filter fixes by confidence level', () => { 299 | const workflow = createMockWorkflow([ 300 | createMockNode('node-1', 'nodes-base.httpRequest', { url: '{{ $json.url }}' }) 301 | ]); 302 | 303 | const formatIssues: ExpressionFormatIssue[] = [{ 304 | fieldPath: 'url', 305 | currentValue: '{{ $json.url }}', 306 | correctedValue: '={{ $json.url }}', 307 | issueType: 'missing-prefix', 308 | severity: 'error', 309 | explanation: 'Expression must start with =', 310 | nodeName: 'node-1', 311 | nodeId: 'node-1' 312 | } as any]; 313 | 314 | const validationResult: WorkflowValidationResult = { 315 | valid: false, 316 | errors: [], 317 | warnings: [], 318 | statistics: { 319 | totalNodes: 1, 320 | enabledNodes: 1, 321 | triggerNodes: 0, 322 | validConnections: 0, 323 | invalidConnections: 0, 324 | expressionsValidated: 0 325 | }, 326 | suggestions: [] 327 | }; 328 | 329 | const result = autoFixer.generateFixes(workflow, validationResult, formatIssues, { 330 | confidenceThreshold: 'low' 331 | }); 332 | 333 | expect(result.fixes.length).toBeGreaterThan(0); 334 | expect(result.fixes.every(f => ['high', 'medium', 'low'].includes(f.confidence))).toBe(true); 335 | }); 336 | }); 337 | 338 | describe('Summary Generation', () => { 339 | it('should generate appropriate summary for fixes', () => { 340 | const workflow = createMockWorkflow([ 341 | createMockNode('node-1', 'nodes-base.httpRequest', { url: '{{ $json.url }}' }) 342 | ]); 343 | 344 | const formatIssues: ExpressionFormatIssue[] = [{ 345 | fieldPath: 'url', 346 | currentValue: '{{ $json.url }}', 347 | correctedValue: '={{ $json.url }}', 348 | issueType: 'missing-prefix', 349 | severity: 'error', 350 | explanation: 'Expression must start with =', 351 | nodeName: 'node-1', 352 | nodeId: 'node-1' 353 | } as any]; 354 | 355 | const validationResult: WorkflowValidationResult = { 356 | valid: false, 357 | errors: [], 358 | warnings: [], 359 | statistics: { 360 | totalNodes: 1, 361 | enabledNodes: 1, 362 | triggerNodes: 0, 363 | validConnections: 0, 364 | invalidConnections: 0, 365 | expressionsValidated: 0 366 | }, 367 | suggestions: [] 368 | }; 369 | 370 | const result = autoFixer.generateFixes(workflow, validationResult, formatIssues); 371 | 372 | expect(result.summary).toContain('expression format'); 373 | expect(result.stats.total).toBe(1); 374 | expect(result.stats.byType['expression-format']).toBe(1); 375 | }); 376 | 377 | it('should handle empty fixes gracefully', () => { 378 | const workflow = createMockWorkflow([]); 379 | const validationResult: WorkflowValidationResult = { 380 | valid: true, 381 | errors: [], 382 | warnings: [], 383 | statistics: { 384 | totalNodes: 0, 385 | enabledNodes: 0, 386 | triggerNodes: 0, 387 | validConnections: 0, 388 | invalidConnections: 0, 389 | expressionsValidated: 0 390 | }, 391 | suggestions: [] 392 | }; 393 | 394 | const result = autoFixer.generateFixes(workflow, validationResult, []); 395 | 396 | expect(result.summary).toBe('No fixes available'); 397 | expect(result.stats.total).toBe(0); 398 | expect(result.operations).toEqual([]); 399 | }); 400 | }); 401 | }); ``` -------------------------------------------------------------------------------- /tests/unit/utils/node-type-normalizer.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for NodeTypeNormalizer 3 | * 4 | * Comprehensive test suite for the node type normalization utility 5 | * that fixes the critical issue of AI agents producing short-form node types 6 | */ 7 | 8 | import { describe, it, expect } from 'vitest'; 9 | import { NodeTypeNormalizer } from '../../../src/utils/node-type-normalizer'; 10 | 11 | describe('NodeTypeNormalizer', () => { 12 | describe('normalizeToFullForm', () => { 13 | describe('Base nodes', () => { 14 | it('should normalize full base form to short form', () => { 15 | expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.webhook')) 16 | .toBe('nodes-base.webhook'); 17 | }); 18 | 19 | it('should normalize full base form with different node names', () => { 20 | expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.httpRequest')) 21 | .toBe('nodes-base.httpRequest'); 22 | expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.set')) 23 | .toBe('nodes-base.set'); 24 | expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.slack')) 25 | .toBe('nodes-base.slack'); 26 | }); 27 | 28 | it('should leave short base form unchanged', () => { 29 | expect(NodeTypeNormalizer.normalizeToFullForm('nodes-base.webhook')) 30 | .toBe('nodes-base.webhook'); 31 | expect(NodeTypeNormalizer.normalizeToFullForm('nodes-base.httpRequest')) 32 | .toBe('nodes-base.httpRequest'); 33 | }); 34 | }); 35 | 36 | describe('LangChain nodes', () => { 37 | it('should normalize full langchain form to short form', () => { 38 | expect(NodeTypeNormalizer.normalizeToFullForm('@n8n/n8n-nodes-langchain.agent')) 39 | .toBe('nodes-langchain.agent'); 40 | expect(NodeTypeNormalizer.normalizeToFullForm('@n8n/n8n-nodes-langchain.openAi')) 41 | .toBe('nodes-langchain.openAi'); 42 | }); 43 | 44 | it('should normalize langchain form with n8n- prefix but missing @n8n/', () => { 45 | expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-langchain.agent')) 46 | .toBe('nodes-langchain.agent'); 47 | }); 48 | 49 | it('should leave short langchain form unchanged', () => { 50 | expect(NodeTypeNormalizer.normalizeToFullForm('nodes-langchain.agent')) 51 | .toBe('nodes-langchain.agent'); 52 | expect(NodeTypeNormalizer.normalizeToFullForm('nodes-langchain.openAi')) 53 | .toBe('nodes-langchain.openAi'); 54 | }); 55 | }); 56 | 57 | describe('Edge cases', () => { 58 | it('should handle empty string', () => { 59 | expect(NodeTypeNormalizer.normalizeToFullForm('')).toBe(''); 60 | }); 61 | 62 | it('should handle null', () => { 63 | expect(NodeTypeNormalizer.normalizeToFullForm(null as any)).toBe(null); 64 | }); 65 | 66 | it('should handle undefined', () => { 67 | expect(NodeTypeNormalizer.normalizeToFullForm(undefined as any)).toBe(undefined); 68 | }); 69 | 70 | it('should handle non-string input', () => { 71 | expect(NodeTypeNormalizer.normalizeToFullForm(123 as any)).toBe(123); 72 | expect(NodeTypeNormalizer.normalizeToFullForm({} as any)).toEqual({}); 73 | }); 74 | 75 | it('should leave community nodes unchanged', () => { 76 | expect(NodeTypeNormalizer.normalizeToFullForm('custom-package.myNode')) 77 | .toBe('custom-package.myNode'); 78 | }); 79 | 80 | it('should leave nodes without prefixes unchanged', () => { 81 | expect(NodeTypeNormalizer.normalizeToFullForm('someRandomNode')) 82 | .toBe('someRandomNode'); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('normalizeWithDetails', () => { 88 | it('should return normalization details for full base form', () => { 89 | const result = NodeTypeNormalizer.normalizeWithDetails('n8n-nodes-base.webhook'); 90 | 91 | expect(result).toEqual({ 92 | original: 'n8n-nodes-base.webhook', 93 | normalized: 'nodes-base.webhook', 94 | wasNormalized: true, 95 | package: 'base' 96 | }); 97 | }); 98 | 99 | it('should return normalization details for already short form', () => { 100 | const result = NodeTypeNormalizer.normalizeWithDetails('nodes-base.webhook'); 101 | 102 | expect(result).toEqual({ 103 | original: 'nodes-base.webhook', 104 | normalized: 'nodes-base.webhook', 105 | wasNormalized: false, 106 | package: 'base' 107 | }); 108 | }); 109 | 110 | it('should detect langchain package', () => { 111 | const result = NodeTypeNormalizer.normalizeWithDetails('@n8n/n8n-nodes-langchain.agent'); 112 | 113 | expect(result).toEqual({ 114 | original: '@n8n/n8n-nodes-langchain.agent', 115 | normalized: 'nodes-langchain.agent', 116 | wasNormalized: true, 117 | package: 'langchain' 118 | }); 119 | }); 120 | 121 | it('should detect community package', () => { 122 | const result = NodeTypeNormalizer.normalizeWithDetails('custom-package.myNode'); 123 | 124 | expect(result).toEqual({ 125 | original: 'custom-package.myNode', 126 | normalized: 'custom-package.myNode', 127 | wasNormalized: false, 128 | package: 'community' 129 | }); 130 | }); 131 | 132 | it('should detect unknown package', () => { 133 | const result = NodeTypeNormalizer.normalizeWithDetails('unknownNode'); 134 | 135 | expect(result).toEqual({ 136 | original: 'unknownNode', 137 | normalized: 'unknownNode', 138 | wasNormalized: false, 139 | package: 'unknown' 140 | }); 141 | }); 142 | }); 143 | 144 | describe('normalizeBatch', () => { 145 | it('should normalize multiple node types', () => { 146 | const types = ['n8n-nodes-base.webhook', 'n8n-nodes-base.set', '@n8n/n8n-nodes-langchain.agent']; 147 | const result = NodeTypeNormalizer.normalizeBatch(types); 148 | 149 | expect(result.size).toBe(3); 150 | expect(result.get('n8n-nodes-base.webhook')).toBe('nodes-base.webhook'); 151 | expect(result.get('n8n-nodes-base.set')).toBe('nodes-base.set'); 152 | expect(result.get('@n8n/n8n-nodes-langchain.agent')).toBe('nodes-langchain.agent'); 153 | }); 154 | 155 | it('should handle empty array', () => { 156 | const result = NodeTypeNormalizer.normalizeBatch([]); 157 | expect(result.size).toBe(0); 158 | }); 159 | 160 | it('should handle mixed forms', () => { 161 | const types = [ 162 | 'n8n-nodes-base.webhook', 163 | 'nodes-base.set', 164 | '@n8n/n8n-nodes-langchain.agent', 165 | 'nodes-langchain.openAi' 166 | ]; 167 | const result = NodeTypeNormalizer.normalizeBatch(types); 168 | 169 | expect(result.size).toBe(4); 170 | expect(result.get('n8n-nodes-base.webhook')).toBe('nodes-base.webhook'); 171 | expect(result.get('nodes-base.set')).toBe('nodes-base.set'); 172 | expect(result.get('@n8n/n8n-nodes-langchain.agent')).toBe('nodes-langchain.agent'); 173 | expect(result.get('nodes-langchain.openAi')).toBe('nodes-langchain.openAi'); 174 | }); 175 | }); 176 | 177 | describe('normalizeWorkflowNodeTypes', () => { 178 | it('should normalize all nodes in workflow', () => { 179 | const workflow = { 180 | nodes: [ 181 | { type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] }, 182 | { type: 'n8n-nodes-base.set', id: '2', name: 'Set', parameters: {}, position: [100, 100] } 183 | ], 184 | connections: {} 185 | }; 186 | 187 | const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); 188 | 189 | expect(result.nodes[0].type).toBe('nodes-base.webhook'); 190 | expect(result.nodes[1].type).toBe('nodes-base.set'); 191 | }); 192 | 193 | it('should preserve all other node properties', () => { 194 | const workflow = { 195 | nodes: [ 196 | { 197 | type: 'n8n-nodes-base.webhook', 198 | id: 'test-id', 199 | name: 'Test Webhook', 200 | parameters: { path: '/test' }, 201 | position: [250, 300], 202 | credentials: { webhookAuth: { id: '1', name: 'Test' } } 203 | } 204 | ], 205 | connections: {} 206 | }; 207 | 208 | const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); 209 | 210 | expect(result.nodes[0]).toEqual({ 211 | type: 'nodes-base.webhook', // normalized to short form 212 | id: 'test-id', // preserved 213 | name: 'Test Webhook', // preserved 214 | parameters: { path: '/test' }, // preserved 215 | position: [250, 300], // preserved 216 | credentials: { webhookAuth: { id: '1', name: 'Test' } } // preserved 217 | }); 218 | }); 219 | 220 | it('should preserve workflow properties', () => { 221 | const workflow = { 222 | name: 'Test Workflow', 223 | active: true, 224 | nodes: [ 225 | { type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] } 226 | ], 227 | connections: { 228 | '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } 229 | } 230 | }; 231 | 232 | const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); 233 | 234 | expect(result.name).toBe('Test Workflow'); 235 | expect(result.active).toBe(true); 236 | expect(result.connections).toEqual({ 237 | '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } 238 | }); 239 | }); 240 | 241 | it('should handle workflow without nodes', () => { 242 | const workflow = { connections: {} }; 243 | const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); 244 | expect(result).toEqual(workflow); 245 | }); 246 | 247 | it('should handle null workflow', () => { 248 | const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(null); 249 | expect(result).toBe(null); 250 | }); 251 | 252 | it('should handle workflow with empty nodes array', () => { 253 | const workflow = { nodes: [], connections: {} }; 254 | const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); 255 | expect(result.nodes).toEqual([]); 256 | }); 257 | }); 258 | 259 | describe('isFullForm', () => { 260 | it('should return true for full base form', () => { 261 | expect(NodeTypeNormalizer.isFullForm('n8n-nodes-base.webhook')).toBe(true); 262 | }); 263 | 264 | it('should return true for full langchain form', () => { 265 | expect(NodeTypeNormalizer.isFullForm('@n8n/n8n-nodes-langchain.agent')).toBe(true); 266 | expect(NodeTypeNormalizer.isFullForm('n8n-nodes-langchain.agent')).toBe(true); 267 | }); 268 | 269 | it('should return false for short base form', () => { 270 | expect(NodeTypeNormalizer.isFullForm('nodes-base.webhook')).toBe(false); 271 | }); 272 | 273 | it('should return false for short langchain form', () => { 274 | expect(NodeTypeNormalizer.isFullForm('nodes-langchain.agent')).toBe(false); 275 | }); 276 | 277 | it('should return false for community nodes', () => { 278 | expect(NodeTypeNormalizer.isFullForm('custom-package.myNode')).toBe(false); 279 | }); 280 | 281 | it('should return false for null/undefined', () => { 282 | expect(NodeTypeNormalizer.isFullForm(null as any)).toBe(false); 283 | expect(NodeTypeNormalizer.isFullForm(undefined as any)).toBe(false); 284 | }); 285 | }); 286 | 287 | describe('isShortForm', () => { 288 | it('should return true for short base form', () => { 289 | expect(NodeTypeNormalizer.isShortForm('nodes-base.webhook')).toBe(true); 290 | }); 291 | 292 | it('should return true for short langchain form', () => { 293 | expect(NodeTypeNormalizer.isShortForm('nodes-langchain.agent')).toBe(true); 294 | }); 295 | 296 | it('should return false for full base form', () => { 297 | expect(NodeTypeNormalizer.isShortForm('n8n-nodes-base.webhook')).toBe(false); 298 | }); 299 | 300 | it('should return false for full langchain form', () => { 301 | expect(NodeTypeNormalizer.isShortForm('@n8n/n8n-nodes-langchain.agent')).toBe(false); 302 | expect(NodeTypeNormalizer.isShortForm('n8n-nodes-langchain.agent')).toBe(false); 303 | }); 304 | 305 | it('should return false for community nodes', () => { 306 | expect(NodeTypeNormalizer.isShortForm('custom-package.myNode')).toBe(false); 307 | }); 308 | 309 | it('should return false for null/undefined', () => { 310 | expect(NodeTypeNormalizer.isShortForm(null as any)).toBe(false); 311 | expect(NodeTypeNormalizer.isShortForm(undefined as any)).toBe(false); 312 | }); 313 | }); 314 | 315 | describe('Integration scenarios', () => { 316 | it('should handle the critical use case from P0-R1', () => { 317 | // This is the exact scenario - normalize full form to match database 318 | const fullFormType = 'n8n-nodes-base.webhook'; // External source produces this 319 | const normalized = NodeTypeNormalizer.normalizeToFullForm(fullFormType); 320 | 321 | expect(normalized).toBe('nodes-base.webhook'); // Database stores in short form 322 | }); 323 | 324 | it('should work correctly in a workflow validation scenario', () => { 325 | const workflow = { 326 | nodes: [ 327 | { type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] }, 328 | { type: 'n8n-nodes-base.httpRequest', id: '2', name: 'HTTP', parameters: {}, position: [200, 0] }, 329 | { type: 'nodes-base.set', id: '3', name: 'Set', parameters: {}, position: [400, 0] } 330 | ], 331 | connections: {} 332 | }; 333 | 334 | const normalized = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); 335 | 336 | // All node types should now be in short form for database lookup 337 | expect(normalized.nodes.every((n: any) => n.type.startsWith('nodes-base.'))).toBe(true); 338 | }); 339 | }); 340 | }); 341 | ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-fixed-collection-validation.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Workflow Fixed Collection Validation Tests 3 | * Tests that workflow validation catches fixedCollection structure errors at the workflow level 4 | */ 5 | 6 | import { describe, test, expect, beforeEach, vi } from 'vitest'; 7 | import { WorkflowValidator } from '../../../src/services/workflow-validator'; 8 | import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; 9 | import { NodeRepository } from '../../../src/database/node-repository'; 10 | 11 | describe('Workflow FixedCollection Validation', () => { 12 | let validator: WorkflowValidator; 13 | let mockNodeRepository: any; 14 | 15 | beforeEach(() => { 16 | // Create mock repository that returns basic node info for common nodes 17 | mockNodeRepository = { 18 | getNode: vi.fn().mockImplementation((type: string) => { 19 | const normalizedType = type.replace('n8n-nodes-base.', '').replace('nodes-base.', ''); 20 | switch (normalizedType) { 21 | case 'webhook': 22 | return { 23 | nodeType: 'nodes-base.webhook', 24 | displayName: 'Webhook', 25 | properties: [ 26 | { name: 'path', type: 'string', required: true }, 27 | { name: 'httpMethod', type: 'options' } 28 | ] 29 | }; 30 | case 'switch': 31 | return { 32 | nodeType: 'nodes-base.switch', 33 | displayName: 'Switch', 34 | properties: [ 35 | { name: 'rules', type: 'fixedCollection', required: true } 36 | ] 37 | }; 38 | case 'if': 39 | return { 40 | nodeType: 'nodes-base.if', 41 | displayName: 'If', 42 | properties: [ 43 | { name: 'conditions', type: 'filter', required: true } 44 | ] 45 | }; 46 | case 'filter': 47 | return { 48 | nodeType: 'nodes-base.filter', 49 | displayName: 'Filter', 50 | properties: [ 51 | { name: 'conditions', type: 'filter', required: true } 52 | ] 53 | }; 54 | default: 55 | return null; 56 | } 57 | }) 58 | }; 59 | 60 | validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); 61 | }); 62 | 63 | test('should catch invalid Switch node structure in workflow validation', async () => { 64 | const workflow = { 65 | name: 'Test Workflow with Invalid Switch', 66 | nodes: [ 67 | { 68 | id: 'webhook', 69 | name: 'Webhook', 70 | type: 'n8n-nodes-base.webhook', 71 | position: [0, 0] as [number, number], 72 | parameters: { 73 | path: 'test-webhook' 74 | } 75 | }, 76 | { 77 | id: 'switch', 78 | name: 'Switch', 79 | type: 'n8n-nodes-base.switch', 80 | position: [200, 0] as [number, number], 81 | parameters: { 82 | // This is the problematic structure that causes "propertyValues[itemName] is not iterable" 83 | rules: { 84 | conditions: { 85 | values: [ 86 | { 87 | value1: '={{$json.status}}', 88 | operation: 'equals', 89 | value2: 'active' 90 | } 91 | ] 92 | } 93 | } 94 | } 95 | } 96 | ], 97 | connections: { 98 | Webhook: { 99 | main: [[{ node: 'Switch', type: 'main', index: 0 }]] 100 | } 101 | } 102 | }; 103 | 104 | const result = await validator.validateWorkflow(workflow, { 105 | validateNodes: true, 106 | profile: 'ai-friendly' 107 | }); 108 | 109 | expect(result.valid).toBe(false); 110 | expect(result.errors).toHaveLength(1); 111 | 112 | const switchError = result.errors.find(e => e.nodeId === 'switch'); 113 | expect(switchError).toBeDefined(); 114 | expect(switchError!.message).toContain('propertyValues[itemName] is not iterable'); 115 | expect(switchError!.message).toContain('Invalid structure for nodes-base.switch node'); 116 | }); 117 | 118 | test('should catch invalid If node structure in workflow validation', async () => { 119 | const workflow = { 120 | name: 'Test Workflow with Invalid If', 121 | nodes: [ 122 | { 123 | id: 'webhook', 124 | name: 'Webhook', 125 | type: 'n8n-nodes-base.webhook', 126 | position: [0, 0] as [number, number], 127 | parameters: { 128 | path: 'test-webhook' 129 | } 130 | }, 131 | { 132 | id: 'if', 133 | name: 'If', 134 | type: 'n8n-nodes-base.if', 135 | position: [200, 0] as [number, number], 136 | parameters: { 137 | // This is the problematic structure 138 | conditions: { 139 | values: [ 140 | { 141 | value1: '={{$json.age}}', 142 | operation: 'largerEqual', 143 | value2: 18 144 | } 145 | ] 146 | } 147 | } 148 | } 149 | ], 150 | connections: { 151 | Webhook: { 152 | main: [[{ node: 'If', type: 'main', index: 0 }]] 153 | } 154 | } 155 | }; 156 | 157 | const result = await validator.validateWorkflow(workflow, { 158 | validateNodes: true, 159 | profile: 'ai-friendly' 160 | }); 161 | 162 | expect(result.valid).toBe(false); 163 | expect(result.errors).toHaveLength(1); 164 | 165 | const ifError = result.errors.find(e => e.nodeId === 'if'); 166 | expect(ifError).toBeDefined(); 167 | expect(ifError!.message).toContain('Invalid structure for nodes-base.if node'); 168 | }); 169 | 170 | test('should accept valid Switch node structure in workflow validation', async () => { 171 | const workflow = { 172 | name: 'Test Workflow with Valid Switch', 173 | nodes: [ 174 | { 175 | id: 'webhook', 176 | name: 'Webhook', 177 | type: 'n8n-nodes-base.webhook', 178 | position: [0, 0] as [number, number], 179 | parameters: { 180 | path: 'test-webhook' 181 | } 182 | }, 183 | { 184 | id: 'switch', 185 | name: 'Switch', 186 | type: 'n8n-nodes-base.switch', 187 | position: [200, 0] as [number, number], 188 | parameters: { 189 | // This is the correct structure 190 | rules: { 191 | values: [ 192 | { 193 | conditions: { 194 | value1: '={{$json.status}}', 195 | operation: 'equals', 196 | value2: 'active' 197 | }, 198 | outputKey: 'active' 199 | } 200 | ] 201 | } 202 | } 203 | } 204 | ], 205 | connections: { 206 | Webhook: { 207 | main: [[{ node: 'Switch', type: 'main', index: 0 }]] 208 | } 209 | } 210 | }; 211 | 212 | const result = await validator.validateWorkflow(workflow, { 213 | validateNodes: true, 214 | profile: 'ai-friendly' 215 | }); 216 | 217 | // Should not have fixedCollection structure errors 218 | const hasFixedCollectionError = result.errors.some(e => 219 | e.message.includes('propertyValues[itemName] is not iterable') 220 | ); 221 | expect(hasFixedCollectionError).toBe(false); 222 | }); 223 | 224 | test('should catch multiple fixedCollection errors in a single workflow', async () => { 225 | const workflow = { 226 | name: 'Test Workflow with Multiple Invalid Structures', 227 | nodes: [ 228 | { 229 | id: 'webhook', 230 | name: 'Webhook', 231 | type: 'n8n-nodes-base.webhook', 232 | position: [0, 0] as [number, number], 233 | parameters: { 234 | path: 'test-webhook' 235 | } 236 | }, 237 | { 238 | id: 'switch', 239 | name: 'Switch', 240 | type: 'n8n-nodes-base.switch', 241 | position: [200, 0] as [number, number], 242 | parameters: { 243 | rules: { 244 | conditions: { 245 | values: [{ value1: 'test', operation: 'equals', value2: 'test' }] 246 | } 247 | } 248 | } 249 | }, 250 | { 251 | id: 'if', 252 | name: 'If', 253 | type: 'n8n-nodes-base.if', 254 | position: [400, 0] as [number, number], 255 | parameters: { 256 | conditions: { 257 | values: [{ value1: 'test', operation: 'equals', value2: 'test' }] 258 | } 259 | } 260 | }, 261 | { 262 | id: 'filter', 263 | name: 'Filter', 264 | type: 'n8n-nodes-base.filter', 265 | position: [600, 0] as [number, number], 266 | parameters: { 267 | conditions: { 268 | values: [{ value1: 'test', operation: 'equals', value2: 'test' }] 269 | } 270 | } 271 | } 272 | ], 273 | connections: { 274 | Webhook: { 275 | main: [[{ node: 'Switch', type: 'main', index: 0 }]] 276 | }, 277 | Switch: { 278 | main: [ 279 | [{ node: 'If', type: 'main', index: 0 }], 280 | [{ node: 'Filter', type: 'main', index: 0 }] 281 | ] 282 | } 283 | } 284 | }; 285 | 286 | const result = await validator.validateWorkflow(workflow, { 287 | validateNodes: true, 288 | profile: 'ai-friendly' 289 | }); 290 | 291 | expect(result.valid).toBe(false); 292 | expect(result.errors.length).toBeGreaterThanOrEqual(3); // At least one error for each problematic node 293 | 294 | // Check that each problematic node has an error 295 | const switchError = result.errors.find(e => e.nodeId === 'switch'); 296 | const ifError = result.errors.find(e => e.nodeId === 'if'); 297 | const filterError = result.errors.find(e => e.nodeId === 'filter'); 298 | 299 | expect(switchError).toBeDefined(); 300 | expect(ifError).toBeDefined(); 301 | expect(filterError).toBeDefined(); 302 | }); 303 | 304 | test('should provide helpful statistics about fixedCollection errors', async () => { 305 | const workflow = { 306 | name: 'Test Workflow Statistics', 307 | nodes: [ 308 | { 309 | id: 'webhook', 310 | name: 'Webhook', 311 | type: 'n8n-nodes-base.webhook', 312 | position: [0, 0] as [number, number], 313 | parameters: { path: 'test' } 314 | }, 315 | { 316 | id: 'bad-switch', 317 | name: 'Bad Switch', 318 | type: 'n8n-nodes-base.switch', 319 | position: [200, 0] as [number, number], 320 | parameters: { 321 | rules: { 322 | conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] } 323 | } 324 | } 325 | }, 326 | { 327 | id: 'good-switch', 328 | name: 'Good Switch', 329 | type: 'n8n-nodes-base.switch', 330 | position: [400, 0] as [number, number], 331 | parameters: { 332 | rules: { 333 | values: [{ conditions: { value1: 'test', operation: 'equals', value2: 'test' }, outputKey: 'out' }] 334 | } 335 | } 336 | } 337 | ], 338 | connections: { 339 | Webhook: { 340 | main: [ 341 | [{ node: 'Bad Switch', type: 'main', index: 0 }], 342 | [{ node: 'Good Switch', type: 'main', index: 0 }] 343 | ] 344 | } 345 | } 346 | }; 347 | 348 | const result = await validator.validateWorkflow(workflow, { 349 | validateNodes: true, 350 | profile: 'ai-friendly' 351 | }); 352 | 353 | expect(result.statistics.totalNodes).toBe(3); 354 | expect(result.statistics.enabledNodes).toBe(3); 355 | expect(result.valid).toBe(false); // Should be invalid due to the bad switch 356 | 357 | // Should have at least one error for the bad switch 358 | const badSwitchError = result.errors.find(e => e.nodeId === 'bad-switch'); 359 | expect(badSwitchError).toBeDefined(); 360 | 361 | // Should not have errors for the good switch or webhook 362 | const goodSwitchError = result.errors.find(e => e.nodeId === 'good-switch'); 363 | const webhookError = result.errors.find(e => e.nodeId === 'webhook'); 364 | 365 | // These might have other validation errors, but not fixedCollection errors 366 | if (goodSwitchError) { 367 | expect(goodSwitchError.message).not.toContain('propertyValues[itemName] is not iterable'); 368 | } 369 | if (webhookError) { 370 | expect(webhookError.message).not.toContain('propertyValues[itemName] is not iterable'); 371 | } 372 | }); 373 | 374 | test('should work with different validation profiles', async () => { 375 | const workflow = { 376 | name: 'Test Profile Compatibility', 377 | nodes: [ 378 | { 379 | id: 'switch', 380 | name: 'Switch', 381 | type: 'n8n-nodes-base.switch', 382 | position: [0, 0] as [number, number], 383 | parameters: { 384 | rules: { 385 | conditions: { 386 | values: [{ value1: 'test', operation: 'equals', value2: 'test' }] 387 | } 388 | } 389 | } 390 | } 391 | ], 392 | connections: {} 393 | }; 394 | 395 | const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> = 396 | ['strict', 'runtime', 'ai-friendly', 'minimal']; 397 | 398 | for (const profile of profiles) { 399 | const result = await validator.validateWorkflow(workflow, { 400 | validateNodes: true, 401 | profile 402 | }); 403 | 404 | // All profiles should catch this critical error 405 | const hasCriticalError = result.errors.some(e => 406 | e.message.includes('propertyValues[itemName] is not iterable') 407 | ); 408 | 409 | expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true); 410 | expect(result.valid, `Profile ${profile} should mark workflow as invalid`).toBe(false); 411 | } 412 | }); 413 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/fixed-collection-validation.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Fixed Collection Validation Tests 3 | * Tests for the fix of issue #90: "propertyValues[itemName] is not iterable" error 4 | * 5 | * This ensures AI agents cannot create invalid fixedCollection structures that break n8n UI 6 | */ 7 | 8 | import { describe, test, expect } from 'vitest'; 9 | import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; 10 | 11 | describe('FixedCollection Validation', () => { 12 | describe('Switch Node v2/v3 Validation', () => { 13 | test('should detect invalid nested conditions structure', () => { 14 | const invalidConfig = { 15 | rules: { 16 | conditions: { 17 | values: [ 18 | { 19 | value1: '={{$json.status}}', 20 | operation: 'equals', 21 | value2: 'active' 22 | } 23 | ] 24 | } 25 | } 26 | }; 27 | 28 | const result = EnhancedConfigValidator.validateWithMode( 29 | 'nodes-base.switch', 30 | invalidConfig, 31 | [], 32 | 'operation', 33 | 'ai-friendly' 34 | ); 35 | 36 | expect(result.valid).toBe(false); 37 | expect(result.errors).toHaveLength(1); 38 | expect(result.errors[0].type).toBe('invalid_value'); 39 | expect(result.errors[0].property).toBe('rules'); 40 | expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable'); 41 | expect(result.errors[0].fix).toContain('{ "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }'); 42 | }); 43 | 44 | test('should detect direct conditions in rules (another invalid pattern)', () => { 45 | const invalidConfig = { 46 | rules: { 47 | conditions: { 48 | value1: '={{$json.status}}', 49 | operation: 'equals', 50 | value2: 'active' 51 | } 52 | } 53 | }; 54 | 55 | const result = EnhancedConfigValidator.validateWithMode( 56 | 'nodes-base.switch', 57 | invalidConfig, 58 | [], 59 | 'operation', 60 | 'ai-friendly' 61 | ); 62 | 63 | expect(result.valid).toBe(false); 64 | expect(result.errors).toHaveLength(1); 65 | expect(result.errors[0].message).toContain('Invalid structure for nodes-base.switch node'); 66 | }); 67 | 68 | test('should provide auto-fix for invalid switch structure', () => { 69 | const invalidConfig = { 70 | rules: { 71 | conditions: { 72 | values: [ 73 | { 74 | value1: '={{$json.status}}', 75 | operation: 'equals', 76 | value2: 'active' 77 | } 78 | ] 79 | } 80 | } 81 | }; 82 | 83 | const result = EnhancedConfigValidator.validateWithMode( 84 | 'nodes-base.switch', 85 | invalidConfig, 86 | [], 87 | 'operation', 88 | 'ai-friendly' 89 | ); 90 | 91 | expect(result.autofix).toBeDefined(); 92 | expect(result.autofix!.rules).toBeDefined(); 93 | expect(result.autofix!.rules.values).toBeInstanceOf(Array); 94 | expect(result.autofix!.rules.values).toHaveLength(1); 95 | expect(result.autofix!.rules.values[0]).toHaveProperty('conditions'); 96 | expect(result.autofix!.rules.values[0]).toHaveProperty('outputKey'); 97 | }); 98 | 99 | test('should accept valid switch structure', () => { 100 | const validConfig = { 101 | rules: { 102 | values: [ 103 | { 104 | conditions: { 105 | value1: '={{$json.status}}', 106 | operation: 'equals', 107 | value2: 'active' 108 | }, 109 | outputKey: 'active' 110 | } 111 | ] 112 | } 113 | }; 114 | 115 | const result = EnhancedConfigValidator.validateWithMode( 116 | 'nodes-base.switch', 117 | validConfig, 118 | [], 119 | 'operation', 120 | 'ai-friendly' 121 | ); 122 | 123 | // Should not have the specific fixedCollection error 124 | const hasFixedCollectionError = result.errors.some(e => 125 | e.message.includes('propertyValues[itemName] is not iterable') 126 | ); 127 | expect(hasFixedCollectionError).toBe(false); 128 | }); 129 | 130 | test('should warn about missing outputKey in valid structure', () => { 131 | const configMissingOutputKey = { 132 | rules: { 133 | values: [ 134 | { 135 | conditions: { 136 | value1: '={{$json.status}}', 137 | operation: 'equals', 138 | value2: 'active' 139 | } 140 | // Missing outputKey 141 | } 142 | ] 143 | } 144 | }; 145 | 146 | const result = EnhancedConfigValidator.validateWithMode( 147 | 'nodes-base.switch', 148 | configMissingOutputKey, 149 | [], 150 | 'operation', 151 | 'ai-friendly' 152 | ); 153 | 154 | const hasOutputKeyWarning = result.warnings.some(w => 155 | w.message.includes('missing "outputKey" property') 156 | ); 157 | expect(hasOutputKeyWarning).toBe(true); 158 | }); 159 | }); 160 | 161 | describe('If Node Validation', () => { 162 | test('should detect invalid nested values structure', () => { 163 | const invalidConfig = { 164 | conditions: { 165 | values: [ 166 | { 167 | value1: '={{$json.age}}', 168 | operation: 'largerEqual', 169 | value2: 18 170 | } 171 | ] 172 | } 173 | }; 174 | 175 | const result = EnhancedConfigValidator.validateWithMode( 176 | 'nodes-base.if', 177 | invalidConfig, 178 | [], 179 | 'operation', 180 | 'ai-friendly' 181 | ); 182 | 183 | expect(result.valid).toBe(false); 184 | expect(result.errors).toHaveLength(1); 185 | expect(result.errors[0].type).toBe('invalid_value'); 186 | expect(result.errors[0].property).toBe('conditions'); 187 | expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node'); 188 | expect(result.errors[0].fix).toBe('Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"'); 189 | }); 190 | 191 | test('should provide auto-fix for invalid if structure', () => { 192 | const invalidConfig = { 193 | conditions: { 194 | values: [ 195 | { 196 | value1: '={{$json.age}}', 197 | operation: 'largerEqual', 198 | value2: 18 199 | } 200 | ] 201 | } 202 | }; 203 | 204 | const result = EnhancedConfigValidator.validateWithMode( 205 | 'nodes-base.if', 206 | invalidConfig, 207 | [], 208 | 'operation', 209 | 'ai-friendly' 210 | ); 211 | 212 | expect(result.autofix).toBeDefined(); 213 | expect(result.autofix!.conditions).toEqual(invalidConfig.conditions.values); 214 | }); 215 | 216 | test('should accept valid if structure', () => { 217 | const validConfig = { 218 | conditions: { 219 | value1: '={{$json.age}}', 220 | operation: 'largerEqual', 221 | value2: 18 222 | } 223 | }; 224 | 225 | const result = EnhancedConfigValidator.validateWithMode( 226 | 'nodes-base.if', 227 | validConfig, 228 | [], 229 | 'operation', 230 | 'ai-friendly' 231 | ); 232 | 233 | // Should not have the specific structure error 234 | const hasStructureError = result.errors.some(e => 235 | e.message.includes('should be a filter object/array directly') 236 | ); 237 | expect(hasStructureError).toBe(false); 238 | }); 239 | }); 240 | 241 | describe('Filter Node Validation', () => { 242 | test('should detect invalid nested values structure', () => { 243 | const invalidConfig = { 244 | conditions: { 245 | values: [ 246 | { 247 | value1: '={{$json.score}}', 248 | operation: 'larger', 249 | value2: 80 250 | } 251 | ] 252 | } 253 | }; 254 | 255 | const result = EnhancedConfigValidator.validateWithMode( 256 | 'nodes-base.filter', 257 | invalidConfig, 258 | [], 259 | 'operation', 260 | 'ai-friendly' 261 | ); 262 | 263 | expect(result.valid).toBe(false); 264 | expect(result.errors).toHaveLength(1); 265 | expect(result.errors[0].type).toBe('invalid_value'); 266 | expect(result.errors[0].property).toBe('conditions'); 267 | expect(result.errors[0].message).toContain('Invalid structure for nodes-base.filter node'); 268 | }); 269 | 270 | test('should accept valid filter structure', () => { 271 | const validConfig = { 272 | conditions: { 273 | value1: '={{$json.score}}', 274 | operation: 'larger', 275 | value2: 80 276 | } 277 | }; 278 | 279 | const result = EnhancedConfigValidator.validateWithMode( 280 | 'nodes-base.filter', 281 | validConfig, 282 | [], 283 | 'operation', 284 | 'ai-friendly' 285 | ); 286 | 287 | // Should not have the specific structure error 288 | const hasStructureError = result.errors.some(e => 289 | e.message.includes('should be a filter object/array directly') 290 | ); 291 | expect(hasStructureError).toBe(false); 292 | }); 293 | }); 294 | 295 | describe('Edge Cases', () => { 296 | test('should not validate non-problematic nodes', () => { 297 | const config = { 298 | someProperty: { 299 | conditions: { 300 | values: ['should', 'not', 'trigger', 'validation'] 301 | } 302 | } 303 | }; 304 | 305 | const result = EnhancedConfigValidator.validateWithMode( 306 | 'nodes-base.httpRequest', 307 | config, 308 | [], 309 | 'operation', 310 | 'ai-friendly' 311 | ); 312 | 313 | // Should not have fixedCollection errors for non-problematic nodes 314 | const hasFixedCollectionError = result.errors.some(e => 315 | e.message.includes('propertyValues[itemName] is not iterable') 316 | ); 317 | expect(hasFixedCollectionError).toBe(false); 318 | }); 319 | 320 | test('should handle empty config gracefully', () => { 321 | const result = EnhancedConfigValidator.validateWithMode( 322 | 'nodes-base.switch', 323 | {}, 324 | [], 325 | 'operation', 326 | 'ai-friendly' 327 | ); 328 | 329 | // Should not crash or produce false positives 330 | expect(result).toBeDefined(); 331 | expect(result.errors).toBeInstanceOf(Array); 332 | }); 333 | 334 | test('should handle non-object property values', () => { 335 | const config = { 336 | rules: 'not an object' 337 | }; 338 | 339 | const result = EnhancedConfigValidator.validateWithMode( 340 | 'nodes-base.switch', 341 | config, 342 | [], 343 | 'operation', 344 | 'ai-friendly' 345 | ); 346 | 347 | // Should not crash on non-object values 348 | expect(result).toBeDefined(); 349 | expect(result.errors).toBeInstanceOf(Array); 350 | }); 351 | }); 352 | 353 | describe('Real-world AI Agent Patterns', () => { 354 | test('should catch common ChatGPT/Claude switch patterns', () => { 355 | // This is a pattern commonly generated by AI agents 356 | const aiGeneratedConfig = { 357 | rules: { 358 | conditions: { 359 | values: [ 360 | { 361 | "value1": "={{$json.status}}", 362 | "operation": "equals", 363 | "value2": "active" 364 | }, 365 | { 366 | "value1": "={{$json.priority}}", 367 | "operation": "equals", 368 | "value2": "high" 369 | } 370 | ] 371 | } 372 | } 373 | }; 374 | 375 | const result = EnhancedConfigValidator.validateWithMode( 376 | 'nodes-base.switch', 377 | aiGeneratedConfig, 378 | [], 379 | 'operation', 380 | 'ai-friendly' 381 | ); 382 | 383 | expect(result.valid).toBe(false); 384 | expect(result.errors).toHaveLength(1); 385 | expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable'); 386 | 387 | // Check auto-fix generates correct structure 388 | expect(result.autofix!.rules.values).toHaveLength(2); 389 | result.autofix!.rules.values.forEach((rule: any) => { 390 | expect(rule).toHaveProperty('conditions'); 391 | expect(rule).toHaveProperty('outputKey'); 392 | }); 393 | }); 394 | 395 | test('should catch common AI if/filter patterns', () => { 396 | const aiGeneratedIfConfig = { 397 | conditions: { 398 | values: { 399 | "value1": "={{$json.age}}", 400 | "operation": "largerEqual", 401 | "value2": 21 402 | } 403 | } 404 | }; 405 | 406 | const result = EnhancedConfigValidator.validateWithMode( 407 | 'nodes-base.if', 408 | aiGeneratedIfConfig, 409 | [], 410 | 'operation', 411 | 'ai-friendly' 412 | ); 413 | 414 | expect(result.valid).toBe(false); 415 | expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node'); 416 | }); 417 | }); 418 | 419 | describe('Version Compatibility', () => { 420 | test('should work across different validation profiles', () => { 421 | const invalidConfig = { 422 | rules: { 423 | conditions: { 424 | values: [{ value1: 'test', operation: 'equals', value2: 'test' }] 425 | } 426 | } 427 | }; 428 | 429 | const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> = 430 | ['strict', 'runtime', 'ai-friendly', 'minimal']; 431 | 432 | profiles.forEach(profile => { 433 | const result = EnhancedConfigValidator.validateWithMode( 434 | 'nodes-base.switch', 435 | invalidConfig, 436 | [], 437 | 'operation', 438 | profile 439 | ); 440 | 441 | // All profiles should catch this critical error 442 | const hasCriticalError = result.errors.some(e => 443 | e.message.includes('propertyValues[itemName] is not iterable') 444 | ); 445 | 446 | expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true); 447 | }); 448 | }); 449 | }); 450 | }); ``` -------------------------------------------------------------------------------- /src/telemetry/config-manager.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Telemetry Configuration Manager 3 | * Handles telemetry settings, opt-in/opt-out, and first-run detection 4 | */ 5 | 6 | import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; 7 | import { join, resolve, dirname } from 'path'; 8 | import { homedir } from 'os'; 9 | import { createHash } from 'crypto'; 10 | import { hostname, platform, arch } from 'os'; 11 | 12 | export interface TelemetryConfig { 13 | enabled: boolean; 14 | userId: string; 15 | firstRun?: string; 16 | lastModified?: string; 17 | version?: string; 18 | } 19 | 20 | export class TelemetryConfigManager { 21 | private static instance: TelemetryConfigManager; 22 | private readonly configDir: string; 23 | private readonly configPath: string; 24 | private config: TelemetryConfig | null = null; 25 | 26 | private constructor() { 27 | this.configDir = join(homedir(), '.n8n-mcp'); 28 | this.configPath = join(this.configDir, 'telemetry.json'); 29 | } 30 | 31 | static getInstance(): TelemetryConfigManager { 32 | if (!TelemetryConfigManager.instance) { 33 | TelemetryConfigManager.instance = new TelemetryConfigManager(); 34 | } 35 | return TelemetryConfigManager.instance; 36 | } 37 | 38 | /** 39 | * Generate a deterministic anonymous user ID based on machine characteristics 40 | * Uses Docker/cloud-specific method for containerized environments 41 | */ 42 | private generateUserId(): string { 43 | // Use boot_id for all Docker/cloud environments (stable across container updates) 44 | if (process.env.IS_DOCKER === 'true' || this.isCloudEnvironment()) { 45 | return this.generateDockerStableId(); 46 | } 47 | 48 | // Local installations use file-based method with hostname 49 | const machineId = `${hostname()}-${platform()}-${arch()}-${homedir()}`; 50 | return createHash('sha256').update(machineId).digest('hex').substring(0, 16); 51 | } 52 | 53 | /** 54 | * Generate stable user ID for Docker/cloud environments 55 | * Priority: boot_id → combined signals → generic fallback 56 | */ 57 | private generateDockerStableId(): string { 58 | // Priority 1: Try boot_id (stable across container recreations) 59 | const bootId = this.readBootId(); 60 | if (bootId) { 61 | const fingerprint = `${bootId}-${platform()}-${arch()}`; 62 | return createHash('sha256').update(fingerprint).digest('hex').substring(0, 16); 63 | } 64 | 65 | // Priority 2: Try combined host signals 66 | const combinedFingerprint = this.generateCombinedFingerprint(); 67 | if (combinedFingerprint) { 68 | return combinedFingerprint; 69 | } 70 | 71 | // Priority 3: Generic Docker ID (allows aggregate statistics) 72 | const genericId = `docker-${platform()}-${arch()}`; 73 | return createHash('sha256').update(genericId).digest('hex').substring(0, 16); 74 | } 75 | 76 | /** 77 | * Read host boot_id from /proc (available in Linux containers) 78 | * Returns null if not available or invalid format 79 | */ 80 | private readBootId(): string | null { 81 | try { 82 | const bootIdPath = '/proc/sys/kernel/random/boot_id'; 83 | 84 | if (!existsSync(bootIdPath)) { 85 | return null; 86 | } 87 | 88 | const bootId = readFileSync(bootIdPath, 'utf-8').trim(); 89 | 90 | // Validate UUID format (8-4-4-4-12 hex digits) 91 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 92 | if (!uuidRegex.test(bootId)) { 93 | return null; 94 | } 95 | 96 | return bootId; 97 | } catch (error) { 98 | // File not readable or other error 99 | return null; 100 | } 101 | } 102 | 103 | /** 104 | * Generate fingerprint from combined host signals 105 | * Fallback for environments where boot_id is not available 106 | */ 107 | private generateCombinedFingerprint(): string | null { 108 | try { 109 | const signals: string[] = []; 110 | 111 | // CPU cores (stable) 112 | if (existsSync('/proc/cpuinfo')) { 113 | const cpuinfo = readFileSync('/proc/cpuinfo', 'utf-8'); 114 | const cores = (cpuinfo.match(/processor\s*:/g) || []).length; 115 | if (cores > 0) { 116 | signals.push(`cores:${cores}`); 117 | } 118 | } 119 | 120 | // Memory (stable) 121 | if (existsSync('/proc/meminfo')) { 122 | const meminfo = readFileSync('/proc/meminfo', 'utf-8'); 123 | const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/); 124 | if (totalMatch) { 125 | signals.push(`mem:${totalMatch[1]}`); 126 | } 127 | } 128 | 129 | // Kernel version (stable) 130 | if (existsSync('/proc/version')) { 131 | const version = readFileSync('/proc/version', 'utf-8'); 132 | const kernelMatch = version.match(/Linux version ([\d.]+)/); 133 | if (kernelMatch) { 134 | signals.push(`kernel:${kernelMatch[1]}`); 135 | } 136 | } 137 | 138 | // Platform and arch 139 | signals.push(platform(), arch()); 140 | 141 | // Need at least 3 signals for reasonable uniqueness 142 | if (signals.length < 3) { 143 | return null; 144 | } 145 | 146 | const fingerprint = signals.join('-'); 147 | return createHash('sha256').update(fingerprint).digest('hex').substring(0, 16); 148 | } catch (error) { 149 | return null; 150 | } 151 | } 152 | 153 | /** 154 | * Check if running in a cloud environment 155 | */ 156 | private isCloudEnvironment(): boolean { 157 | return !!( 158 | process.env.RAILWAY_ENVIRONMENT || 159 | process.env.RENDER || 160 | process.env.FLY_APP_NAME || 161 | process.env.HEROKU_APP_NAME || 162 | process.env.AWS_EXECUTION_ENV || 163 | process.env.KUBERNETES_SERVICE_HOST || 164 | process.env.GOOGLE_CLOUD_PROJECT || 165 | process.env.AZURE_FUNCTIONS_ENVIRONMENT 166 | ); 167 | } 168 | 169 | /** 170 | * Load configuration from disk or create default 171 | */ 172 | loadConfig(): TelemetryConfig { 173 | if (this.config) { 174 | return this.config; 175 | } 176 | 177 | if (!existsSync(this.configPath)) { 178 | // First run - create default config 179 | const version = this.getPackageVersion(); 180 | 181 | // Check if telemetry is disabled via environment variable 182 | const envDisabled = this.isDisabledByEnvironment(); 183 | 184 | this.config = { 185 | enabled: !envDisabled, // Respect env var on first run 186 | userId: this.generateUserId(), 187 | firstRun: new Date().toISOString(), 188 | version 189 | }; 190 | 191 | this.saveConfig(); 192 | 193 | // Only show notice if not disabled via environment 194 | if (!envDisabled) { 195 | this.showFirstRunNotice(); 196 | } 197 | 198 | return this.config; 199 | } 200 | 201 | try { 202 | const rawConfig = readFileSync(this.configPath, 'utf-8'); 203 | this.config = JSON.parse(rawConfig); 204 | 205 | // Ensure userId exists (for upgrades from older versions) 206 | if (!this.config!.userId) { 207 | this.config!.userId = this.generateUserId(); 208 | this.saveConfig(); 209 | } 210 | 211 | return this.config!; 212 | } catch (error) { 213 | console.error('Failed to load telemetry config, using defaults:', error); 214 | this.config = { 215 | enabled: false, 216 | userId: this.generateUserId() 217 | }; 218 | return this.config; 219 | } 220 | } 221 | 222 | /** 223 | * Save configuration to disk 224 | */ 225 | private saveConfig(): void { 226 | if (!this.config) return; 227 | 228 | try { 229 | if (!existsSync(this.configDir)) { 230 | mkdirSync(this.configDir, { recursive: true }); 231 | } 232 | 233 | this.config.lastModified = new Date().toISOString(); 234 | writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); 235 | } catch (error) { 236 | console.error('Failed to save telemetry config:', error); 237 | } 238 | } 239 | 240 | /** 241 | * Check if telemetry is enabled 242 | * Priority: Environment variable > Config file > Default (true) 243 | */ 244 | isEnabled(): boolean { 245 | // Check environment variables first (for Docker users) 246 | if (this.isDisabledByEnvironment()) { 247 | return false; 248 | } 249 | 250 | const config = this.loadConfig(); 251 | return config.enabled; 252 | } 253 | 254 | /** 255 | * Check if telemetry is disabled via environment variable 256 | */ 257 | private isDisabledByEnvironment(): boolean { 258 | const envVars = [ 259 | 'N8N_MCP_TELEMETRY_DISABLED', 260 | 'TELEMETRY_DISABLED', 261 | 'DISABLE_TELEMETRY' 262 | ]; 263 | 264 | for (const varName of envVars) { 265 | const value = process.env[varName]; 266 | if (value !== undefined) { 267 | const normalized = value.toLowerCase().trim(); 268 | 269 | // Warn about invalid values 270 | if (!['true', 'false', '1', '0', ''].includes(normalized)) { 271 | console.warn( 272 | `⚠️ Invalid telemetry environment variable value: ${varName}="${value}"\n` + 273 | ` Use "true" to disable or "false" to enable telemetry.` 274 | ); 275 | } 276 | 277 | // Accept common truthy values 278 | if (normalized === 'true' || normalized === '1') { 279 | return true; 280 | } 281 | } 282 | } 283 | 284 | return false; 285 | } 286 | 287 | /** 288 | * Get the anonymous user ID 289 | */ 290 | getUserId(): string { 291 | const config = this.loadConfig(); 292 | return config.userId; 293 | } 294 | 295 | /** 296 | * Check if this is the first run 297 | */ 298 | isFirstRun(): boolean { 299 | return !existsSync(this.configPath); 300 | } 301 | 302 | /** 303 | * Enable telemetry 304 | */ 305 | enable(): void { 306 | const config = this.loadConfig(); 307 | config.enabled = true; 308 | this.config = config; 309 | this.saveConfig(); 310 | console.log('✓ Anonymous telemetry enabled'); 311 | } 312 | 313 | /** 314 | * Disable telemetry 315 | */ 316 | disable(): void { 317 | const config = this.loadConfig(); 318 | config.enabled = false; 319 | this.config = config; 320 | this.saveConfig(); 321 | console.log('✓ Anonymous telemetry disabled'); 322 | } 323 | 324 | /** 325 | * Get current status 326 | */ 327 | getStatus(): string { 328 | const config = this.loadConfig(); 329 | 330 | // Check if disabled by environment 331 | const envDisabled = this.isDisabledByEnvironment(); 332 | 333 | let status = config.enabled ? 'ENABLED' : 'DISABLED'; 334 | if (envDisabled) { 335 | status = 'DISABLED (via environment variable)'; 336 | } 337 | 338 | return ` 339 | Telemetry Status: ${status} 340 | Anonymous ID: ${config.userId} 341 | First Run: ${config.firstRun || 'Unknown'} 342 | Config Path: ${this.configPath} 343 | 344 | To opt-out: npx n8n-mcp telemetry disable 345 | To opt-in: npx n8n-mcp telemetry enable 346 | 347 | For Docker: Set N8N_MCP_TELEMETRY_DISABLED=true 348 | `; 349 | } 350 | 351 | /** 352 | * Show first-run notice to user 353 | */ 354 | private showFirstRunNotice(): void { 355 | console.log(` 356 | ╔════════════════════════════════════════════════════════════╗ 357 | ║ Anonymous Usage Statistics ║ 358 | ╠════════════════════════════════════════════════════════════╣ 359 | ║ ║ 360 | ║ n8n-mcp collects anonymous usage data to improve the ║ 361 | ║ tool and understand how it's being used. ║ 362 | ║ ║ 363 | ║ We track: ║ 364 | ║ • Which MCP tools are used (no parameters) ║ 365 | ║ • Workflow structures (sanitized, no sensitive data) ║ 366 | ║ • Error patterns (hashed, no details) ║ 367 | ║ • Performance metrics (timing, success rates) ║ 368 | ║ ║ 369 | ║ We NEVER collect: ║ 370 | ║ • URLs, API keys, or credentials ║ 371 | ║ • Workflow content or actual data ║ 372 | ║ • Personal or identifiable information ║ 373 | ║ • n8n instance details or locations ║ 374 | ║ ║ 375 | ║ Your anonymous ID: ${this.config?.userId || 'generating...'} ║ 376 | ║ ║ 377 | ║ This helps me understand usage patterns and improve ║ 378 | ║ n8n-mcp for everyone. Thank you for your support! ║ 379 | ║ ║ 380 | ║ To opt-out at any time: ║ 381 | ║ npx n8n-mcp telemetry disable ║ 382 | ║ ║ 383 | ║ Data deletion requests: ║ 384 | ║ Email [email protected] with your anonymous ID ║ 385 | ║ ║ 386 | ║ Learn more: ║ 387 | ║ https://github.com/czlonkowski/n8n-mcp/blob/main/PRIVACY.md ║ 388 | ║ ║ 389 | ╚════════════════════════════════════════════════════════════╝ 390 | `); 391 | } 392 | 393 | /** 394 | * Get package version safely 395 | */ 396 | private getPackageVersion(): string { 397 | try { 398 | // Try multiple approaches to find package.json 399 | const possiblePaths = [ 400 | resolve(__dirname, '..', '..', 'package.json'), 401 | resolve(process.cwd(), 'package.json'), 402 | resolve(__dirname, '..', '..', '..', 'package.json') 403 | ]; 404 | 405 | for (const packagePath of possiblePaths) { 406 | if (existsSync(packagePath)) { 407 | const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8')); 408 | if (packageJson.version) { 409 | return packageJson.version; 410 | } 411 | } 412 | } 413 | 414 | // Fallback: try require (works in some environments) 415 | try { 416 | const packageJson = require('../../package.json'); 417 | return packageJson.version || 'unknown'; 418 | } catch { 419 | // Ignore require error 420 | } 421 | 422 | return 'unknown'; 423 | } catch (error) { 424 | return 'unknown'; 425 | } 426 | } 427 | } ``` -------------------------------------------------------------------------------- /scripts/prepare-release.js: -------------------------------------------------------------------------------- ```javascript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Pre-release preparation script 5 | * Validates and prepares everything needed for a successful release 6 | */ 7 | 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const { execSync, spawnSync } = require('child_process'); 11 | const readline = require('readline'); 12 | 13 | // Color codes 14 | const colors = { 15 | reset: '\x1b[0m', 16 | red: '\x1b[31m', 17 | green: '\x1b[32m', 18 | yellow: '\x1b[33m', 19 | blue: '\x1b[34m', 20 | magenta: '\x1b[35m', 21 | cyan: '\x1b[36m' 22 | }; 23 | 24 | function log(message, color = 'reset') { 25 | console.log(`${colors[color]}${message}${colors.reset}`); 26 | } 27 | 28 | function success(message) { 29 | log(`✅ ${message}`, 'green'); 30 | } 31 | 32 | function warning(message) { 33 | log(`⚠️ ${message}`, 'yellow'); 34 | } 35 | 36 | function error(message) { 37 | log(`❌ ${message}`, 'red'); 38 | } 39 | 40 | function info(message) { 41 | log(`ℹ️ ${message}`, 'blue'); 42 | } 43 | 44 | function header(title) { 45 | log(`\n${'='.repeat(60)}`, 'cyan'); 46 | log(`🚀 ${title}`, 'cyan'); 47 | log(`${'='.repeat(60)}`, 'cyan'); 48 | } 49 | 50 | class ReleasePreparation { 51 | constructor() { 52 | this.rootDir = path.resolve(__dirname, '..'); 53 | this.rl = readline.createInterface({ 54 | input: process.stdin, 55 | output: process.stdout 56 | }); 57 | } 58 | 59 | async askQuestion(question) { 60 | return new Promise((resolve) => { 61 | this.rl.question(question, resolve); 62 | }); 63 | } 64 | 65 | /** 66 | * Get current version and ask for new version 67 | */ 68 | async getVersionInfo() { 69 | const packageJson = require(path.join(this.rootDir, 'package.json')); 70 | const currentVersion = packageJson.version; 71 | 72 | log(`\nCurrent version: ${currentVersion}`, 'blue'); 73 | 74 | const newVersion = await this.askQuestion('\nEnter new version (e.g., 2.10.0): '); 75 | 76 | if (!newVersion || !this.isValidSemver(newVersion)) { 77 | error('Invalid semantic version format'); 78 | throw new Error('Invalid version'); 79 | } 80 | 81 | if (this.compareVersions(newVersion, currentVersion) <= 0) { 82 | error('New version must be greater than current version'); 83 | throw new Error('Version not incremented'); 84 | } 85 | 86 | return { currentVersion, newVersion }; 87 | } 88 | 89 | /** 90 | * Validate semantic version format (strict semver compliance) 91 | */ 92 | isValidSemver(version) { 93 | // Strict semantic versioning regex 94 | const semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; 95 | return semverRegex.test(version); 96 | } 97 | 98 | /** 99 | * Compare two semantic versions 100 | */ 101 | compareVersions(v1, v2) { 102 | const parseVersion = (v) => v.split('-')[0].split('.').map(Number); 103 | const [v1Parts, v2Parts] = [parseVersion(v1), parseVersion(v2)]; 104 | 105 | for (let i = 0; i < 3; i++) { 106 | if (v1Parts[i] > v2Parts[i]) return 1; 107 | if (v1Parts[i] < v2Parts[i]) return -1; 108 | } 109 | return 0; 110 | } 111 | 112 | /** 113 | * Update version in package files 114 | */ 115 | updateVersions(newVersion) { 116 | log('\n📝 Updating version in package files...', 'blue'); 117 | 118 | // Update package.json 119 | const packageJsonPath = path.join(this.rootDir, 'package.json'); 120 | const packageJson = require(packageJsonPath); 121 | packageJson.version = newVersion; 122 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); 123 | success('Updated package.json'); 124 | 125 | // Sync to runtime package 126 | try { 127 | execSync('npm run sync:runtime-version', { cwd: this.rootDir, stdio: 'pipe' }); 128 | success('Synced package.runtime.json'); 129 | } catch (err) { 130 | warning('Could not sync runtime version automatically'); 131 | 132 | // Manual sync 133 | const runtimeJsonPath = path.join(this.rootDir, 'package.runtime.json'); 134 | if (fs.existsSync(runtimeJsonPath)) { 135 | const runtimeJson = require(runtimeJsonPath); 136 | runtimeJson.version = newVersion; 137 | fs.writeFileSync(runtimeJsonPath, JSON.stringify(runtimeJson, null, 2) + '\n'); 138 | success('Manually synced package.runtime.json'); 139 | } 140 | } 141 | } 142 | 143 | /** 144 | * Update changelog 145 | */ 146 | async updateChangelog(newVersion) { 147 | const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md'); 148 | 149 | if (!fs.existsSync(changelogPath)) { 150 | warning('Changelog file not found, skipping update'); 151 | return; 152 | } 153 | 154 | log('\n📋 Updating changelog...', 'blue'); 155 | 156 | const content = fs.readFileSync(changelogPath, 'utf8'); 157 | const today = new Date().toISOString().split('T')[0]; 158 | 159 | // Check if version already exists in changelog 160 | const versionRegex = new RegExp(`^## \\[${newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm'); 161 | if (versionRegex.test(content)) { 162 | info(`Version ${newVersion} already exists in changelog`); 163 | return; 164 | } 165 | 166 | // Find the Unreleased section 167 | const unreleasedMatch = content.match(/^## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/m); 168 | 169 | if (unreleasedMatch) { 170 | const unreleasedContent = unreleasedMatch[1].trim(); 171 | 172 | if (unreleasedContent) { 173 | log('\nFound content in Unreleased section:', 'blue'); 174 | log(unreleasedContent.substring(0, 200) + '...', 'yellow'); 175 | 176 | const moveContent = await this.askQuestion('\nMove this content to the new version? (y/n): '); 177 | 178 | if (moveContent.toLowerCase() === 'y') { 179 | // Move unreleased content to new version 180 | const newVersionSection = `## [${newVersion}] - ${today}\n\n${unreleasedContent}\n\n`; 181 | const updatedContent = content.replace( 182 | /^## \[Unreleased\]\s*\n[\s\S]*?(?=\n## \[)/m, 183 | `## [Unreleased]\n\n${newVersionSection}## [` 184 | ); 185 | 186 | fs.writeFileSync(changelogPath, updatedContent); 187 | success(`Moved unreleased content to version ${newVersion}`); 188 | } else { 189 | // Just add empty version section 190 | const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`; 191 | const updatedContent = content.replace( 192 | /^## \[Unreleased\]\s*\n/m, 193 | `## [Unreleased]\n\n${newVersionSection}` 194 | ); 195 | 196 | fs.writeFileSync(changelogPath, updatedContent); 197 | warning(`Added empty version section for ${newVersion} - please fill in the changes`); 198 | } 199 | } else { 200 | // Add empty version section 201 | const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`; 202 | const updatedContent = content.replace( 203 | /^## \[Unreleased\]\s*\n/m, 204 | `## [Unreleased]\n\n${newVersionSection}` 205 | ); 206 | 207 | fs.writeFileSync(changelogPath, updatedContent); 208 | warning(`Added empty version section for ${newVersion} - please fill in the changes`); 209 | } 210 | } else { 211 | warning('Could not find Unreleased section in changelog'); 212 | } 213 | 214 | info('Please review and edit the changelog before committing'); 215 | } 216 | 217 | /** 218 | * Run tests and build 219 | */ 220 | async runChecks() { 221 | log('\n🧪 Running pre-release checks...', 'blue'); 222 | 223 | try { 224 | // Run tests 225 | log('Running tests...', 'blue'); 226 | execSync('npm test', { cwd: this.rootDir, stdio: 'inherit' }); 227 | success('All tests passed'); 228 | 229 | // Run build 230 | log('Building project...', 'blue'); 231 | execSync('npm run build', { cwd: this.rootDir, stdio: 'inherit' }); 232 | success('Build completed'); 233 | 234 | // Rebuild database 235 | log('Rebuilding database...', 'blue'); 236 | execSync('npm run rebuild', { cwd: this.rootDir, stdio: 'inherit' }); 237 | success('Database rebuilt'); 238 | 239 | // Run type checking 240 | log('Type checking...', 'blue'); 241 | execSync('npm run typecheck', { cwd: this.rootDir, stdio: 'inherit' }); 242 | success('Type checking passed'); 243 | 244 | } catch (err) { 245 | error('Pre-release checks failed'); 246 | throw err; 247 | } 248 | } 249 | 250 | /** 251 | * Create git commit 252 | */ 253 | async createCommit(newVersion) { 254 | log('\n📝 Creating git commit...', 'blue'); 255 | 256 | try { 257 | // Check git status 258 | const status = execSync('git status --porcelain', { 259 | cwd: this.rootDir, 260 | encoding: 'utf8' 261 | }); 262 | 263 | if (!status.trim()) { 264 | info('No changes to commit'); 265 | return; 266 | } 267 | 268 | // Show what will be committed 269 | log('\nFiles to be committed:', 'blue'); 270 | execSync('git diff --name-only', { cwd: this.rootDir, stdio: 'inherit' }); 271 | 272 | const commit = await this.askQuestion('\nCreate commit for release? (y/n): '); 273 | 274 | if (commit.toLowerCase() === 'y') { 275 | // Add files 276 | execSync('git add package.json package.runtime.json docs/CHANGELOG.md', { 277 | cwd: this.rootDir, 278 | stdio: 'pipe' 279 | }); 280 | 281 | // Create commit 282 | const commitMessage = `chore: release v${newVersion} 283 | 284 | 🤖 Generated with [Claude Code](https://claude.ai/code) 285 | 286 | Co-Authored-By: Claude <[email protected]>`; 287 | 288 | const result = spawnSync('git', ['commit', '-m', commitMessage], { 289 | cwd: this.rootDir, 290 | stdio: 'pipe', 291 | encoding: 'utf8' 292 | }); 293 | 294 | if (result.error || result.status !== 0) { 295 | throw new Error(`Git commit failed: ${result.stderr || result.error?.message}`); 296 | } 297 | 298 | success(`Created commit for v${newVersion}`); 299 | 300 | const push = await this.askQuestion('\nPush to trigger release workflow? (y/n): '); 301 | 302 | if (push.toLowerCase() === 'y') { 303 | // Add confirmation for destructive operation 304 | warning('\n⚠️ DESTRUCTIVE OPERATION WARNING ⚠️'); 305 | warning('This will trigger a PUBLIC RELEASE that cannot be undone!'); 306 | warning('The following will happen automatically:'); 307 | warning('• Create GitHub release with tag'); 308 | warning('• Publish package to NPM registry'); 309 | warning('• Build and push Docker images'); 310 | warning('• Update documentation'); 311 | 312 | const confirmation = await this.askQuestion('\nType "RELEASE" (all caps) to confirm: '); 313 | 314 | if (confirmation === 'RELEASE') { 315 | execSync('git push', { cwd: this.rootDir, stdio: 'inherit' }); 316 | success('Pushed to remote repository'); 317 | log('\n🎉 Release workflow will be triggered automatically!', 'green'); 318 | log('Monitor progress at: https://github.com/czlonkowski/n8n-mcp/actions', 'blue'); 319 | } else { 320 | warning('Release cancelled. Commit created but not pushed.'); 321 | info('You can push manually later to trigger the release.'); 322 | } 323 | } else { 324 | info('Commit created but not pushed. Push manually to trigger release.'); 325 | } 326 | } 327 | 328 | } catch (err) { 329 | error(`Git operations failed: ${err.message}`); 330 | throw err; 331 | } 332 | } 333 | 334 | /** 335 | * Display final instructions 336 | */ 337 | displayInstructions(newVersion) { 338 | header('Release Preparation Complete'); 339 | 340 | log('📋 What happens next:', 'blue'); 341 | log(`1. The GitHub Actions workflow will detect the version change to v${newVersion}`, 'green'); 342 | log('2. It will automatically:', 'green'); 343 | log(' • Create a GitHub release with changelog content', 'green'); 344 | log(' • Publish the npm package', 'green'); 345 | log(' • Build and push Docker images', 'green'); 346 | log(' • Update documentation badges', 'green'); 347 | log('\n🔍 Monitor the release at:', 'blue'); 348 | log(' • GitHub Actions: https://github.com/czlonkowski/n8n-mcp/actions', 'blue'); 349 | log(' • NPM Package: https://www.npmjs.com/package/n8n-mcp', 'blue'); 350 | log(' • Docker Images: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp', 'blue'); 351 | 352 | log('\n✅ Release preparation completed successfully!', 'green'); 353 | } 354 | 355 | /** 356 | * Main execution flow 357 | */ 358 | async run() { 359 | try { 360 | header('n8n-MCP Release Preparation'); 361 | 362 | // Get version information 363 | const { currentVersion, newVersion } = await this.getVersionInfo(); 364 | 365 | log(`\n🔄 Preparing release: ${currentVersion} → ${newVersion}`, 'magenta'); 366 | 367 | // Update versions 368 | this.updateVersions(newVersion); 369 | 370 | // Update changelog 371 | await this.updateChangelog(newVersion); 372 | 373 | // Run pre-release checks 374 | await this.runChecks(); 375 | 376 | // Create git commit 377 | await this.createCommit(newVersion); 378 | 379 | // Display final instructions 380 | this.displayInstructions(newVersion); 381 | 382 | } catch (err) { 383 | error(`Release preparation failed: ${err.message}`); 384 | process.exit(1); 385 | } finally { 386 | this.rl.close(); 387 | } 388 | } 389 | } 390 | 391 | // Run the script 392 | if (require.main === module) { 393 | const preparation = new ReleasePreparation(); 394 | preparation.run().catch(err => { 395 | console.error('Release preparation failed:', err); 396 | process.exit(1); 397 | }); 398 | } 399 | 400 | module.exports = ReleasePreparation; ``` -------------------------------------------------------------------------------- /tests/unit/mcp/search-nodes-examples.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | import { N8NDocumentationMCPServer } from '../../../src/mcp/server'; 3 | import { createDatabaseAdapter } from '../../../src/database/database-adapter'; 4 | import path from 'path'; 5 | import fs from 'fs'; 6 | 7 | /** 8 | * Unit tests for search_nodes with includeExamples parameter 9 | * Testing P0-R3 feature: Template-based configuration examples 10 | */ 11 | 12 | describe('search_nodes with includeExamples', () => { 13 | let server: N8NDocumentationMCPServer; 14 | let dbPath: string; 15 | 16 | beforeEach(async () => { 17 | // Use in-memory database for testing 18 | process.env.NODE_DB_PATH = ':memory:'; 19 | server = new N8NDocumentationMCPServer(); 20 | await (server as any).initialized; 21 | 22 | // Populate in-memory database with test nodes 23 | // NOTE: Database stores nodes in SHORT form (nodes-base.xxx, not n8n-nodes-base.xxx) 24 | const testNodes = [ 25 | { 26 | node_type: 'nodes-base.webhook', 27 | package_name: 'n8n-nodes-base', 28 | display_name: 'Webhook', 29 | description: 'Starts workflow on webhook call', 30 | category: 'Core Nodes', 31 | is_ai_tool: 0, 32 | is_trigger: 1, 33 | is_webhook: 1, 34 | is_versioned: 1, 35 | version: '1', 36 | properties_schema: JSON.stringify([]), 37 | operations: JSON.stringify([]) 38 | }, 39 | { 40 | node_type: 'nodes-base.httpRequest', 41 | package_name: 'n8n-nodes-base', 42 | display_name: 'HTTP Request', 43 | description: 'Makes an HTTP request', 44 | category: 'Core Nodes', 45 | is_ai_tool: 0, 46 | is_trigger: 0, 47 | is_webhook: 0, 48 | is_versioned: 1, 49 | version: '1', 50 | properties_schema: JSON.stringify([]), 51 | operations: JSON.stringify([]) 52 | } 53 | ]; 54 | 55 | // Insert test nodes into the in-memory database 56 | const db = (server as any).db; 57 | if (db) { 58 | const insertStmt = db.prepare(` 59 | INSERT INTO nodes ( 60 | node_type, package_name, display_name, description, category, 61 | is_ai_tool, is_trigger, is_webhook, is_versioned, version, 62 | properties_schema, operations 63 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 64 | `); 65 | 66 | for (const node of testNodes) { 67 | insertStmt.run( 68 | node.node_type, 69 | node.package_name, 70 | node.display_name, 71 | node.description, 72 | node.category, 73 | node.is_ai_tool, 74 | node.is_trigger, 75 | node.is_webhook, 76 | node.is_versioned, 77 | node.version, 78 | node.properties_schema, 79 | node.operations 80 | ); 81 | } 82 | // Note: FTS table is not created in test environment 83 | // searchNodes will fall back to LIKE search when FTS doesn't exist 84 | } 85 | }); 86 | 87 | afterEach(() => { 88 | delete process.env.NODE_DB_PATH; 89 | }); 90 | 91 | describe('includeExamples parameter', () => { 92 | it('should not include examples when includeExamples is false', async () => { 93 | const result = await (server as any).searchNodes('webhook', 5, { includeExamples: false }); 94 | 95 | expect(result.results).toBeDefined(); 96 | if (result.results.length > 0) { 97 | result.results.forEach((node: any) => { 98 | expect(node.examples).toBeUndefined(); 99 | }); 100 | } 101 | }); 102 | 103 | it('should not include examples when includeExamples is undefined', async () => { 104 | const result = await (server as any).searchNodes('webhook', 5, {}); 105 | 106 | expect(result.results).toBeDefined(); 107 | if (result.results.length > 0) { 108 | result.results.forEach((node: any) => { 109 | expect(node.examples).toBeUndefined(); 110 | }); 111 | } 112 | }); 113 | 114 | it('should include examples when includeExamples is true', async () => { 115 | const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true }); 116 | 117 | expect(result.results).toBeDefined(); 118 | // Note: In-memory test database may not have template configs 119 | // This test validates the parameter is processed correctly 120 | }); 121 | 122 | it('should handle nodes without examples gracefully', async () => { 123 | const result = await (server as any).searchNodes('nonexistent', 5, { includeExamples: true }); 124 | 125 | expect(result.results).toBeDefined(); 126 | expect(result.results).toHaveLength(0); 127 | }); 128 | 129 | it('should limit examples to top 2 per node', async () => { 130 | // This test would need a database with actual template_node_configs data 131 | // In a real scenario, we'd verify that only 2 examples are returned 132 | const result = await (server as any).searchNodes('http', 5, { includeExamples: true }); 133 | 134 | expect(result.results).toBeDefined(); 135 | if (result.results.length > 0) { 136 | result.results.forEach((node: any) => { 137 | if (node.examples) { 138 | expect(node.examples.length).toBeLessThanOrEqual(2); 139 | } 140 | }); 141 | } 142 | }); 143 | }); 144 | 145 | describe('example data structure', () => { 146 | it('should return examples with correct structure when present', async () => { 147 | // Mock database to return example data 148 | const mockDb = (server as any).db; 149 | if (mockDb) { 150 | const originalPrepare = mockDb.prepare.bind(mockDb); 151 | mockDb.prepare = vi.fn((query: string) => { 152 | if (query.includes('template_node_configs')) { 153 | return { 154 | all: vi.fn(() => [ 155 | { 156 | parameters_json: JSON.stringify({ 157 | httpMethod: 'POST', 158 | path: 'webhook-test' 159 | }), 160 | template_name: 'Test Template', 161 | template_views: 1000 162 | }, 163 | { 164 | parameters_json: JSON.stringify({ 165 | httpMethod: 'GET', 166 | path: 'webhook-get' 167 | }), 168 | template_name: 'Another Template', 169 | template_views: 500 170 | } 171 | ]) 172 | }; 173 | } 174 | return originalPrepare(query); 175 | }); 176 | 177 | const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true }); 178 | 179 | if (result.results.length > 0 && result.results[0].examples) { 180 | const example = result.results[0].examples[0]; 181 | expect(example).toHaveProperty('configuration'); 182 | expect(example).toHaveProperty('template'); 183 | expect(example).toHaveProperty('views'); 184 | expect(typeof example.configuration).toBe('object'); 185 | expect(typeof example.template).toBe('string'); 186 | expect(typeof example.views).toBe('number'); 187 | } 188 | } 189 | }); 190 | }); 191 | 192 | describe('backward compatibility', () => { 193 | it('should maintain backward compatibility when includeExamples not specified', async () => { 194 | const resultWithoutParam = await (server as any).searchNodes('http', 5); 195 | const resultWithFalse = await (server as any).searchNodes('http', 5, { includeExamples: false }); 196 | 197 | expect(resultWithoutParam.results).toBeDefined(); 198 | expect(resultWithFalse.results).toBeDefined(); 199 | 200 | // Both should have same structure (no examples) 201 | if (resultWithoutParam.results.length > 0) { 202 | expect(resultWithoutParam.results[0].examples).toBeUndefined(); 203 | } 204 | if (resultWithFalse.results.length > 0) { 205 | expect(resultWithFalse.results[0].examples).toBeUndefined(); 206 | } 207 | }); 208 | }); 209 | 210 | describe('performance considerations', () => { 211 | it('should not significantly impact performance when includeExamples is false', async () => { 212 | const startWithout = Date.now(); 213 | await (server as any).searchNodes('http', 20, { includeExamples: false }); 214 | const durationWithout = Date.now() - startWithout; 215 | 216 | const startWith = Date.now(); 217 | await (server as any).searchNodes('http', 20, { includeExamples: true }); 218 | const durationWith = Date.now() - startWith; 219 | 220 | // Both should complete quickly (under 100ms) 221 | expect(durationWithout).toBeLessThan(100); 222 | expect(durationWith).toBeLessThan(200); 223 | }); 224 | }); 225 | 226 | describe('error handling', () => { 227 | it('should continue to work even if example fetch fails', async () => { 228 | // Mock database to throw error on example fetch 229 | const mockDb = (server as any).db; 230 | if (mockDb) { 231 | const originalPrepare = mockDb.prepare.bind(mockDb); 232 | mockDb.prepare = vi.fn((query: string) => { 233 | if (query.includes('template_node_configs')) { 234 | throw new Error('Database error'); 235 | } 236 | return originalPrepare(query); 237 | }); 238 | 239 | // Should not throw, should return results without examples 240 | const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true }); 241 | 242 | expect(result.results).toBeDefined(); 243 | // Examples should be undefined due to error 244 | if (result.results.length > 0) { 245 | expect(result.results[0].examples).toBeUndefined(); 246 | } 247 | } 248 | }); 249 | 250 | it('should handle malformed parameters_json gracefully', async () => { 251 | const mockDb = (server as any).db; 252 | if (mockDb) { 253 | const originalPrepare = mockDb.prepare.bind(mockDb); 254 | mockDb.prepare = vi.fn((query: string) => { 255 | if (query.includes('template_node_configs')) { 256 | return { 257 | all: vi.fn(() => [ 258 | { 259 | parameters_json: 'invalid json', 260 | template_name: 'Test Template', 261 | template_views: 1000 262 | } 263 | ]) 264 | }; 265 | } 266 | return originalPrepare(query); 267 | }); 268 | 269 | // Should not throw 270 | const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true }); 271 | expect(result).toBeDefined(); 272 | } 273 | }); 274 | }); 275 | }); 276 | 277 | describe('searchNodesLIKE with includeExamples', () => { 278 | let server: N8NDocumentationMCPServer; 279 | 280 | beforeEach(async () => { 281 | process.env.NODE_DB_PATH = ':memory:'; 282 | server = new N8NDocumentationMCPServer(); 283 | await (server as any).initialized; 284 | 285 | // Populate in-memory database with test nodes 286 | const testNodes = [ 287 | { 288 | node_type: 'nodes-base.webhook', 289 | package_name: 'n8n-nodes-base', 290 | display_name: 'Webhook', 291 | description: 'Starts workflow on webhook call', 292 | category: 'Core Nodes', 293 | is_ai_tool: 0, 294 | is_trigger: 1, 295 | is_webhook: 1, 296 | is_versioned: 1, 297 | version: '1', 298 | properties_schema: JSON.stringify([]), 299 | operations: JSON.stringify([]) 300 | } 301 | ]; 302 | 303 | const db = (server as any).db; 304 | if (db) { 305 | const insertStmt = db.prepare(` 306 | INSERT INTO nodes ( 307 | node_type, package_name, display_name, description, category, 308 | is_ai_tool, is_trigger, is_webhook, is_versioned, version, 309 | properties_schema, operations 310 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 311 | `); 312 | 313 | for (const node of testNodes) { 314 | insertStmt.run( 315 | node.node_type, 316 | node.package_name, 317 | node.display_name, 318 | node.description, 319 | node.category, 320 | node.is_ai_tool, 321 | node.is_trigger, 322 | node.is_webhook, 323 | node.is_versioned, 324 | node.version, 325 | node.properties_schema, 326 | node.operations 327 | ); 328 | } 329 | } 330 | }); 331 | 332 | afterEach(() => { 333 | delete process.env.NODE_DB_PATH; 334 | }); 335 | 336 | it('should support includeExamples in LIKE search', async () => { 337 | const result = await (server as any).searchNodesLIKE('webhook', 5, { includeExamples: true }); 338 | 339 | expect(result).toBeDefined(); 340 | expect(result.results).toBeDefined(); 341 | expect(Array.isArray(result.results)).toBe(true); 342 | }); 343 | 344 | it('should not include examples when includeExamples is false', async () => { 345 | const result = await (server as any).searchNodesLIKE('webhook', 5, { includeExamples: false }); 346 | 347 | expect(result).toBeDefined(); 348 | expect(result.results).toBeDefined(); 349 | if (result.results.length > 0) { 350 | result.results.forEach((node: any) => { 351 | expect(node.examples).toBeUndefined(); 352 | }); 353 | } 354 | }); 355 | }); 356 | 357 | describe('searchNodesFTS with includeExamples', () => { 358 | let server: N8NDocumentationMCPServer; 359 | 360 | beforeEach(async () => { 361 | process.env.NODE_DB_PATH = ':memory:'; 362 | server = new N8NDocumentationMCPServer(); 363 | await (server as any).initialized; 364 | }); 365 | 366 | afterEach(() => { 367 | delete process.env.NODE_DB_PATH; 368 | }); 369 | 370 | it('should support includeExamples in FTS search', async () => { 371 | const result = await (server as any).searchNodesFTS('webhook', 5, 'OR', { includeExamples: true }); 372 | 373 | expect(result.results).toBeDefined(); 374 | expect(Array.isArray(result.results)).toBe(true); 375 | }); 376 | 377 | it('should pass options to example fetching logic', async () => { 378 | const result = await (server as any).searchNodesFTS('http', 5, 'AND', { includeExamples: true }); 379 | 380 | expect(result).toBeDefined(); 381 | expect(result.results).toBeDefined(); 382 | }); 383 | }); 384 | ```