This is page 13 of 45. Use http://codebase.md/czlonkowski/n8n-mcp?lines=false&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-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 │ │ │ ├── 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-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/services/workflow-validator-loops-simple.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { WorkflowValidator } from '@/services/workflow-validator'; import { NodeRepository } from '@/database/node-repository'; import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; // Mock dependencies vi.mock('@/database/node-repository'); vi.mock('@/services/enhanced-config-validator'); describe('WorkflowValidator - SplitInBatches Validation (Simplified)', () => { let validator: WorkflowValidator; let mockNodeRepository: any; let mockNodeValidator: any; beforeEach(() => { vi.clearAllMocks(); mockNodeRepository = { getNode: vi.fn() }; mockNodeValidator = { validateWithMode: vi.fn().mockReturnValue({ errors: [], warnings: [] }) }; validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator); }); describe('SplitInBatches node detection', () => { it('should identify SplitInBatches nodes in workflow', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.splitInBatches', properties: [] }); const workflow = { name: 'SplitInBatches Workflow', nodes: [ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: { batchSize: 10 } }, { id: '2', name: 'Process Item', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: { 'Split In Batches': { main: [ [], // Done output (0) [{ node: 'Process Item', type: 'main', index: 0 }] // Loop output (1) ] } } }; const result = await validator.validateWorkflow(workflow as any); // Should complete validation without crashing expect(result).toBeDefined(); expect(result.valid).toBeDefined(); }); it('should handle SplitInBatches with processing node name patterns', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.splitInBatches', properties: [] }); const processingNames = [ 'Process Item', 'Transform Data', 'Handle Each', 'Function Node', 'Code Block' ]; for (const nodeName of processingNames) { const workflow = { name: 'Processing Pattern Test', nodes: [ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }, { id: '2', name: nodeName, type: 'n8n-nodes-base.function', position: [300, 100], parameters: {} } ], connections: { 'Split In Batches': { main: [ [{ node: nodeName, type: 'main', index: 0 }], // Processing node on Done output [] ] } } }; const result = await validator.validateWorkflow(workflow as any); // Should identify potential processing nodes expect(result).toBeDefined(); } }); it('should handle final processing node patterns', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.splitInBatches', properties: [] }); const finalNames = [ 'Final Summary', 'Send Email', 'Complete Notification', 'Final Report' ]; for (const nodeName of finalNames) { const workflow = { name: 'Final Pattern Test', nodes: [ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }, { id: '2', name: nodeName, type: 'n8n-nodes-base.emailSend', position: [300, 100], parameters: {} } ], connections: { 'Split In Batches': { main: [ [{ node: nodeName, type: 'main', index: 0 }], // Final node on Done output (correct) [] ] } } }; const result = await validator.validateWorkflow(workflow as any); // Should not warn about final nodes on done output expect(result).toBeDefined(); } }); }); describe('Connection validation', () => { it('should validate connection indices', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.splitInBatches', properties: [] }); const workflow = { name: 'Connection Index Test', nodes: [ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }, { id: '2', name: 'Target', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} } ], connections: { 'Split In Batches': { main: [ [{ node: 'Target', type: 'main', index: -1 }] // Invalid negative index ] } } }; const result = await validator.validateWorkflow(workflow as any); const negativeIndexErrors = result.errors.filter(e => e.message?.includes('Invalid connection index -1') ); expect(negativeIndexErrors.length).toBeGreaterThan(0); }); it('should handle non-existent target nodes', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.splitInBatches', properties: [] }); const workflow = { name: 'Missing Target Test', nodes: [ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} } ], connections: { 'Split In Batches': { main: [ [{ node: 'NonExistentNode', type: 'main', index: 0 }] ] } } }; const result = await validator.validateWorkflow(workflow as any); const missingNodeErrors = result.errors.filter(e => e.message?.includes('non-existent node') ); expect(missingNodeErrors.length).toBeGreaterThan(0); }); }); describe('Self-referencing connections', () => { it('should allow self-referencing for SplitInBatches nodes', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.splitInBatches', properties: [] }); const workflow = { name: 'Self Reference Test', nodes: [ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} } ], connections: { 'Split In Batches': { main: [ [], [{ node: 'Split In Batches', type: 'main', index: 0 }] // Self-reference on loop output ] } } }; const result = await validator.validateWorkflow(workflow as any); // Should not warn about self-reference for SplitInBatches const selfRefWarnings = result.warnings.filter(w => w.message?.includes('self-referencing') ); expect(selfRefWarnings).toHaveLength(0); }); it('should warn about self-referencing for non-loop nodes', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.set', properties: [] }); const workflow = { name: 'Non-Loop Self Reference Test', nodes: [ { id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} } ], connections: { 'Set': { main: [ [{ node: 'Set', type: 'main', index: 0 }] // Self-reference on regular node ] } } }; const result = await validator.validateWorkflow(workflow as any); // Should warn about self-reference for non-loop nodes const selfRefWarnings = result.warnings.filter(w => w.message?.includes('self-referencing') ); expect(selfRefWarnings.length).toBeGreaterThan(0); }); }); describe('Output connection validation', () => { it('should validate output connections for nodes with outputs', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.if', outputs: [ { displayName: 'True', description: 'Items that match condition' }, { displayName: 'False', description: 'Items that do not match condition' } ], outputNames: ['true', 'false'], properties: [] }); const workflow = { name: 'IF Node Test', nodes: [ { id: '1', name: 'IF', type: 'n8n-nodes-base.if', position: [100, 100], parameters: {} }, { id: '2', name: 'True Handler', type: 'n8n-nodes-base.set', position: [300, 50], parameters: {} }, { id: '3', name: 'False Handler', type: 'n8n-nodes-base.set', position: [300, 150], parameters: {} } ], connections: { 'IF': { main: [ [{ node: 'True Handler', type: 'main', index: 0 }], // True output (0) [{ node: 'False Handler', type: 'main', index: 0 }] // False output (1) ] } } }; const result = await validator.validateWorkflow(workflow as any); // Should validate without major errors expect(result).toBeDefined(); expect(result.statistics.validConnections).toBe(2); }); }); describe('Error handling', () => { it('should handle nodes without outputs gracefully', async () => { mockNodeRepository.getNode.mockReturnValue({ nodeType: 'nodes-base.httpRequest', outputs: null, outputNames: null, properties: [] }); const workflow = { name: 'No Outputs Test', nodes: [ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} } ], connections: {} }; const result = await validator.validateWorkflow(workflow as any); // Should handle gracefully without crashing expect(result).toBeDefined(); }); it('should handle unknown node types gracefully', async () => { mockNodeRepository.getNode.mockReturnValue(null); const workflow = { name: 'Unknown Node Test', nodes: [ { id: '1', name: 'Unknown', type: 'n8n-nodes-base.unknown', position: [100, 100], parameters: {} } ], connections: {} }; const result = await validator.validateWorkflow(workflow as any); // Should report unknown node error const unknownErrors = result.errors.filter(e => e.message?.includes('Unknown node type') ); expect(unknownErrors.length).toBeGreaterThan(0); }); }); }); ``` -------------------------------------------------------------------------------- /tests/fixtures/template-configs.ts: -------------------------------------------------------------------------------- ```typescript /** * Test fixtures for template node configurations * Used across unit and integration tests for P0-R3 feature */ import * as zlib from 'zlib'; export interface TemplateConfigFixture { node_type: string; template_id: number; template_name: string; template_views: number; node_name: string; parameters_json: string; credentials_json: string | null; has_credentials: number; has_expressions: number; complexity: 'simple' | 'medium' | 'complex'; use_cases: string; rank?: number; } export interface WorkflowFixture { id: string; name: string; nodes: any[]; connections: Record<string, any>; settings?: Record<string, any>; } /** * Sample node configurations for common use cases */ export const sampleConfigs: Record<string, TemplateConfigFixture> = { simpleWebhook: { node_type: 'n8n-nodes-base.webhook', template_id: 1, template_name: 'Simple Webhook Trigger', template_views: 5000, node_name: 'Webhook', parameters_json: JSON.stringify({ httpMethod: 'POST', path: 'webhook', responseMode: 'lastNode', alwaysOutputData: true }), credentials_json: null, has_credentials: 0, has_expressions: 0, complexity: 'simple', use_cases: JSON.stringify(['webhook processing', 'trigger automation']), rank: 1 }, webhookWithAuth: { node_type: 'n8n-nodes-base.webhook', template_id: 2, template_name: 'Authenticated Webhook', template_views: 3000, node_name: 'Webhook', parameters_json: JSON.stringify({ httpMethod: 'POST', path: 'secure-webhook', responseMode: 'responseNode', authentication: 'headerAuth' }), credentials_json: JSON.stringify({ httpHeaderAuth: { id: '1', name: 'Header Auth' } }), has_credentials: 1, has_expressions: 0, complexity: 'medium', use_cases: JSON.stringify(['secure webhook', 'authenticated triggers']), rank: 2 }, httpRequestBasic: { node_type: 'n8n-nodes-base.httpRequest', template_id: 3, template_name: 'Basic HTTP GET Request', template_views: 10000, node_name: 'HTTP Request', parameters_json: JSON.stringify({ url: 'https://api.example.com/data', method: 'GET', responseFormat: 'json', options: { timeout: 10000, redirect: { followRedirects: true } } }), credentials_json: null, has_credentials: 0, has_expressions: 0, complexity: 'simple', use_cases: JSON.stringify(['API calls', 'data fetching']), rank: 1 }, httpRequestWithExpressions: { node_type: 'n8n-nodes-base.httpRequest', template_id: 4, template_name: 'Dynamic HTTP Request', template_views: 7500, node_name: 'HTTP Request', parameters_json: JSON.stringify({ url: '={{ $json.apiUrl }}', method: 'POST', sendBody: true, bodyParameters: { values: [ { name: 'userId', value: '={{ $json.userId }}' }, { name: 'action', value: '={{ $json.action }}' } ] }, options: { timeout: '={{ $json.timeout || 10000 }}' } }), credentials_json: null, has_credentials: 0, has_expressions: 1, complexity: 'complex', use_cases: JSON.stringify(['dynamic API calls', 'expression-based routing']), rank: 2 }, slackMessage: { node_type: 'n8n-nodes-base.slack', template_id: 5, template_name: 'Send Slack Message', template_views: 8000, node_name: 'Slack', parameters_json: JSON.stringify({ resource: 'message', operation: 'post', channel: '#general', text: 'Hello from n8n!' }), credentials_json: JSON.stringify({ slackApi: { id: '2', name: 'Slack API' } }), has_credentials: 1, has_expressions: 0, complexity: 'simple', use_cases: JSON.stringify(['notifications', 'team communication']), rank: 1 }, codeNodeTransform: { node_type: 'n8n-nodes-base.code', template_id: 6, template_name: 'Data Transformation', template_views: 6000, node_name: 'Code', parameters_json: JSON.stringify({ mode: 'runOnceForAllItems', jsCode: `const items = $input.all(); return items.map(item => ({ json: { id: item.json.id, name: item.json.name.toUpperCase(), timestamp: new Date().toISOString() } }));` }), credentials_json: null, has_credentials: 0, has_expressions: 0, complexity: 'medium', use_cases: JSON.stringify(['data transformation', 'custom logic']), rank: 1 }, codeNodeWithExpressions: { node_type: 'n8n-nodes-base.code', template_id: 7, template_name: 'Advanced Code with Expressions', template_views: 4500, node_name: 'Code', parameters_json: JSON.stringify({ mode: 'runOnceForEachItem', jsCode: `const data = $input.item.json; const previousNode = $('HTTP Request').first().json; return { json: { combined: data.value + previousNode.value, nodeRef: $node } };` }), credentials_json: null, has_credentials: 0, has_expressions: 1, complexity: 'complex', use_cases: JSON.stringify(['advanced transformations', 'node references']), rank: 2 } }; /** * Sample workflows for testing extraction */ export const sampleWorkflows: Record<string, WorkflowFixture> = { webhookToSlack: { id: '1', name: 'Webhook to Slack Notification', nodes: [ { id: 'webhook1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 1, position: [250, 300], parameters: { httpMethod: 'POST', path: 'alert', responseMode: 'lastNode' } }, { id: 'slack1', name: 'Slack', type: 'n8n-nodes-base.slack', typeVersion: 1, position: [450, 300], parameters: { resource: 'message', operation: 'post', channel: '#alerts', text: '={{ $json.message }}' }, credentials: { slackApi: { id: '1', name: 'Slack API' } } } ], connections: { webhook1: { main: [[{ node: 'slack1', type: 'main', index: 0 }]] } }, settings: {} }, apiWorkflow: { id: '2', name: 'API Data Processing', nodes: [ { id: 'http1', name: 'Fetch Data', type: 'n8n-nodes-base.httpRequest', typeVersion: 3, position: [250, 300], parameters: { url: 'https://api.example.com/users', method: 'GET', responseFormat: 'json' } }, { id: 'code1', name: 'Transform', type: 'n8n-nodes-base.code', typeVersion: 2, position: [450, 300], parameters: { mode: 'runOnceForAllItems', jsCode: 'return $input.all().map(item => ({ json: { ...item.json, processed: true } }));' } }, { id: 'http2', name: 'Send Results', type: 'n8n-nodes-base.httpRequest', typeVersion: 3, position: [650, 300], parameters: { url: '={{ $json.callbackUrl }}', method: 'POST', sendBody: true, bodyParameters: { values: [ { name: 'data', value: '={{ JSON.stringify($json) }}' } ] } } } ], connections: { http1: { main: [[{ node: 'code1', type: 'main', index: 0 }]] }, code1: { main: [[{ node: 'http2', type: 'main', index: 0 }]] } }, settings: {} }, complexWorkflow: { id: '3', name: 'Complex Multi-Node Workflow', nodes: [ { id: 'webhook1', name: 'Start', type: 'n8n-nodes-base.webhook', typeVersion: 1, position: [100, 300], parameters: { httpMethod: 'POST', path: 'start' } }, { id: 'sticky1', name: 'Note', type: 'n8n-nodes-base.stickyNote', typeVersion: 1, position: [100, 200], parameters: { content: 'This workflow processes incoming data' } }, { id: 'if1', name: 'Check Type', type: 'n8n-nodes-base.if', typeVersion: 1, position: [300, 300], parameters: { conditions: { boolean: [ { value1: '={{ $json.type }}', value2: 'premium' } ] } } }, { id: 'http1', name: 'Premium API', type: 'n8n-nodes-base.httpRequest', typeVersion: 3, position: [500, 200], parameters: { url: 'https://api.example.com/premium', method: 'POST' } }, { id: 'http2', name: 'Standard API', type: 'n8n-nodes-base.httpRequest', typeVersion: 3, position: [500, 400], parameters: { url: 'https://api.example.com/standard', method: 'POST' } } ], connections: { webhook1: { main: [[{ node: 'if1', type: 'main', index: 0 }]] }, if1: { main: [ [{ node: 'http1', type: 'main', index: 0 }], [{ node: 'http2', type: 'main', index: 0 }] ] } }, settings: {} } }; /** * Compress workflow to base64 (mimics n8n template format) */ export function compressWorkflow(workflow: WorkflowFixture): string { const json = JSON.stringify(workflow); return zlib.gzipSync(Buffer.from(json, 'utf-8')).toString('base64'); } /** * Create template metadata */ export function createTemplateMetadata(complexity: 'simple' | 'medium' | 'complex', useCases: string[]) { return { complexity, use_cases: useCases }; } /** * Batch create configs for testing */ export function createConfigBatch(nodeType: string, count: number): TemplateConfigFixture[] { return Array.from({ length: count }, (_, i) => ({ node_type: nodeType, template_id: i + 1, template_name: `Template ${i + 1}`, template_views: 1000 - (i * 50), node_name: `Node ${i + 1}`, parameters_json: JSON.stringify({ index: i }), credentials_json: null, has_credentials: 0, has_expressions: 0, complexity: (['simple', 'medium', 'complex'] as const)[i % 3], use_cases: JSON.stringify(['test use case']), rank: i + 1 })); } /** * Get config by complexity */ export function getConfigByComplexity(complexity: 'simple' | 'medium' | 'complex'): TemplateConfigFixture { const configs = Object.values(sampleConfigs); const match = configs.find(c => c.complexity === complexity); return match || configs[0]; } /** * Get configs with expressions */ export function getConfigsWithExpressions(): TemplateConfigFixture[] { return Object.values(sampleConfigs).filter(c => c.has_expressions === 1); } /** * Get configs with credentials */ export function getConfigsWithCredentials(): TemplateConfigFixture[] { return Object.values(sampleConfigs).filter(c => c.has_credentials === 1); } /** * Mock database insert helper */ export function createInsertStatement(config: TemplateConfigFixture): string { return `INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, credentials_json, has_credentials, has_expressions, complexity, use_cases, rank ) VALUES ( '${config.node_type}', ${config.template_id}, '${config.template_name}', ${config.template_views}, '${config.node_name}', '${config.parameters_json.replace(/'/g, "''")}', ${config.credentials_json ? `'${config.credentials_json.replace(/'/g, "''")}'` : 'NULL'}, ${config.has_credentials}, ${config.has_expressions}, '${config.complexity}', '${config.use_cases.replace(/'/g, "''")}', ${config.rank || 0} )`; } ``` -------------------------------------------------------------------------------- /src/services/n8n-api-client.ts: -------------------------------------------------------------------------------- ```typescript import axios, { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; import { logger } from '../utils/logger'; import { Workflow, WorkflowListParams, WorkflowListResponse, Execution, ExecutionListParams, ExecutionListResponse, Credential, CredentialListParams, CredentialListResponse, Tag, TagListParams, TagListResponse, HealthCheckResponse, Variable, WebhookRequest, WorkflowExport, WorkflowImport, SourceControlStatus, SourceControlPullResult, SourceControlPushResult, } from '../types/n8n-api'; import { handleN8nApiError, logN8nError } from '../utils/n8n-errors'; import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation'; export interface N8nApiClientConfig { baseUrl: string; apiKey: string; timeout?: number; maxRetries?: number; } export class N8nApiClient { private client: AxiosInstance; private maxRetries: number; constructor(config: N8nApiClientConfig) { const { baseUrl, apiKey, timeout = 30000, maxRetries = 3 } = config; this.maxRetries = maxRetries; // Ensure baseUrl ends with /api/v1 const apiUrl = baseUrl.endsWith('/api/v1') ? baseUrl : `${baseUrl.replace(/\/$/, '')}/api/v1`; this.client = axios.create({ baseURL: apiUrl, timeout, headers: { 'X-N8N-API-KEY': apiKey, 'Content-Type': 'application/json', }, }); // Request interceptor for logging this.client.interceptors.request.use( (config: InternalAxiosRequestConfig) => { logger.debug(`n8n API Request: ${config.method?.toUpperCase()} ${config.url}`, { params: config.params, data: config.data, }); return config; }, (error: unknown) => { logger.error('n8n API Request Error:', error); return Promise.reject(error); } ); // Response interceptor for logging this.client.interceptors.response.use( (response: any) => { logger.debug(`n8n API Response: ${response.status} ${response.config.url}`); return response; }, (error: unknown) => { const n8nError = handleN8nApiError(error); logN8nError(n8nError, 'n8n API Response'); return Promise.reject(n8nError); } ); } // Health check to verify API connectivity async healthCheck(): Promise<HealthCheckResponse> { try { // Try the standard healthz endpoint (available on all n8n instances) const baseUrl = this.client.defaults.baseURL || ''; const healthzUrl = baseUrl.replace(/\/api\/v\d+\/?$/, '') + '/healthz'; const response = await axios.get(healthzUrl, { timeout: 5000, validateStatus: (status) => status < 500 }); if (response.status === 200 && response.data?.status === 'ok') { return { status: 'ok', features: {} // Features detection would require additional endpoints }; } // If healthz doesn't work, fall back to API check throw new Error('healthz endpoint not available'); } catch (error) { // If healthz endpoint doesn't exist, try listing workflows with limit 1 // This is a fallback for older n8n versions try { await this.client.get('/workflows', { params: { limit: 1 } }); return { status: 'ok', features: {} }; } catch (fallbackError) { throw handleN8nApiError(fallbackError); } } } // Workflow Management async createWorkflow(workflow: Partial<Workflow>): Promise<Workflow> { try { const cleanedWorkflow = cleanWorkflowForCreate(workflow); const response = await this.client.post('/workflows', cleanedWorkflow); return response.data; } catch (error) { throw handleN8nApiError(error); } } async getWorkflow(id: string): Promise<Workflow> { try { const response = await this.client.get(`/workflows/${id}`); return response.data; } catch (error) { throw handleN8nApiError(error); } } async updateWorkflow(id: string, workflow: Partial<Workflow>): Promise<Workflow> { try { // First, try PUT method (newer n8n versions) const cleanedWorkflow = cleanWorkflowForUpdate(workflow as Workflow); try { const response = await this.client.put(`/workflows/${id}`, cleanedWorkflow); return response.data; } catch (putError: any) { // If PUT fails with 405 (Method Not Allowed), try PATCH if (putError.response?.status === 405) { logger.debug('PUT method not supported, falling back to PATCH'); const response = await this.client.patch(`/workflows/${id}`, cleanedWorkflow); return response.data; } throw putError; } } catch (error) { throw handleN8nApiError(error); } } async deleteWorkflow(id: string): Promise<Workflow> { try { const response = await this.client.delete(`/workflows/${id}`); return response.data; } catch (error) { throw handleN8nApiError(error); } } async listWorkflows(params: WorkflowListParams = {}): Promise<WorkflowListResponse> { try { const response = await this.client.get('/workflows', { params }); return response.data; } catch (error) { throw handleN8nApiError(error); } } // Execution Management async getExecution(id: string, includeData = false): Promise<Execution> { try { const response = await this.client.get(`/executions/${id}`, { params: { includeData }, }); return response.data; } catch (error) { throw handleN8nApiError(error); } } async listExecutions(params: ExecutionListParams = {}): Promise<ExecutionListResponse> { try { const response = await this.client.get('/executions', { params }); return response.data; } catch (error) { throw handleN8nApiError(error); } } async deleteExecution(id: string): Promise<void> { try { await this.client.delete(`/executions/${id}`); } catch (error) { throw handleN8nApiError(error); } } // Webhook Execution async triggerWebhook(request: WebhookRequest): Promise<any> { try { const { webhookUrl, httpMethod, data, headers, waitForResponse = true } = request; // SECURITY: Validate URL for SSRF protection (includes DNS resolution) // See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-03) const { SSRFProtection } = await import('../utils/ssrf-protection'); const validation = await SSRFProtection.validateWebhookUrl(webhookUrl); if (!validation.valid) { throw new Error(`SSRF protection: ${validation.reason}`); } // Extract path from webhook URL const url = new URL(webhookUrl); const webhookPath = url.pathname; // Make request directly to webhook endpoint const config: AxiosRequestConfig = { method: httpMethod, url: webhookPath, headers: { ...headers, // Don't override API key header for webhook endpoints 'X-N8N-API-KEY': undefined, }, data: httpMethod !== 'GET' ? data : undefined, params: httpMethod === 'GET' ? data : undefined, // Webhooks might take longer timeout: waitForResponse ? 120000 : 30000, }; // Create a new axios instance for webhook requests to avoid API interceptors const webhookClient = axios.create({ baseURL: new URL('/', webhookUrl).toString(), validateStatus: (status) => status < 500, // Don't throw on 4xx }); const response = await webhookClient.request(config); return { status: response.status, statusText: response.statusText, data: response.data, headers: response.headers, }; } catch (error) { throw handleN8nApiError(error); } } // Credential Management async listCredentials(params: CredentialListParams = {}): Promise<CredentialListResponse> { try { const response = await this.client.get('/credentials', { params }); return response.data; } catch (error) { throw handleN8nApiError(error); } } async getCredential(id: string): Promise<Credential> { try { const response = await this.client.get(`/credentials/${id}`); return response.data; } catch (error) { throw handleN8nApiError(error); } } async createCredential(credential: Partial<Credential>): Promise<Credential> { try { const response = await this.client.post('/credentials', credential); return response.data; } catch (error) { throw handleN8nApiError(error); } } async updateCredential(id: string, credential: Partial<Credential>): Promise<Credential> { try { const response = await this.client.patch(`/credentials/${id}`, credential); return response.data; } catch (error) { throw handleN8nApiError(error); } } async deleteCredential(id: string): Promise<void> { try { await this.client.delete(`/credentials/${id}`); } catch (error) { throw handleN8nApiError(error); } } // Tag Management async listTags(params: TagListParams = {}): Promise<TagListResponse> { try { const response = await this.client.get('/tags', { params }); return response.data; } catch (error) { throw handleN8nApiError(error); } } async createTag(tag: Partial<Tag>): Promise<Tag> { try { const response = await this.client.post('/tags', tag); return response.data; } catch (error) { throw handleN8nApiError(error); } } async updateTag(id: string, tag: Partial<Tag>): Promise<Tag> { try { const response = await this.client.patch(`/tags/${id}`, tag); return response.data; } catch (error) { throw handleN8nApiError(error); } } async deleteTag(id: string): Promise<void> { try { await this.client.delete(`/tags/${id}`); } catch (error) { throw handleN8nApiError(error); } } // Source Control Management (Enterprise feature) async getSourceControlStatus(): Promise<SourceControlStatus> { try { const response = await this.client.get('/source-control/status'); return response.data; } catch (error) { throw handleN8nApiError(error); } } async pullSourceControl(force = false): Promise<SourceControlPullResult> { try { const response = await this.client.post('/source-control/pull', { force }); return response.data; } catch (error) { throw handleN8nApiError(error); } } async pushSourceControl( message: string, fileNames?: string[] ): Promise<SourceControlPushResult> { try { const response = await this.client.post('/source-control/push', { message, fileNames, }); return response.data; } catch (error) { throw handleN8nApiError(error); } } // Variable Management (via Source Control API) async getVariables(): Promise<Variable[]> { try { const response = await this.client.get('/variables'); return response.data.data || []; } catch (error) { // Variables might not be available in all n8n versions logger.warn('Variables API not available, returning empty array'); return []; } } async createVariable(variable: Partial<Variable>): Promise<Variable> { try { const response = await this.client.post('/variables', variable); return response.data; } catch (error) { throw handleN8nApiError(error); } } async updateVariable(id: string, variable: Partial<Variable>): Promise<Variable> { try { const response = await this.client.patch(`/variables/${id}`, variable); return response.data; } catch (error) { throw handleN8nApiError(error); } } async deleteVariable(id: string): Promise<void> { try { await this.client.delete(`/variables/${id}`); } catch (error) { throw handleN8nApiError(error); } } } ``` -------------------------------------------------------------------------------- /tests/unit/monitoring/cache-metrics.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Unit tests for cache metrics monitoring functionality */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { getInstanceCacheMetrics, getN8nApiClient, clearInstanceCache } from '../../../src/mcp/handlers-n8n-manager'; import { cacheMetrics, getCacheStatistics } from '../../../src/utils/cache-utils'; import { InstanceContext } from '../../../src/types/instance-context'; // Mock the N8nApiClient vi.mock('../../../src/clients/n8n-api-client', () => ({ N8nApiClient: vi.fn().mockImplementation((config) => ({ config, getWorkflows: vi.fn().mockResolvedValue([]), getWorkflow: vi.fn().mockResolvedValue({}), isConnected: vi.fn().mockReturnValue(true) })) })); // Mock logger to reduce noise in tests vi.mock('../../../src/utils/logger', () => { const mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; return { Logger: vi.fn().mockImplementation(() => mockLogger), logger: mockLogger }; }); describe('Cache Metrics Monitoring', () => { beforeEach(() => { // Clear cache before each test clearInstanceCache(); cacheMetrics.reset(); // Reset environment variables delete process.env.N8N_API_URL; delete process.env.N8N_API_KEY; delete process.env.INSTANCE_CACHE_MAX; delete process.env.INSTANCE_CACHE_TTL_MINUTES; }); afterEach(() => { vi.clearAllMocks(); }); describe('getInstanceCacheStatistics', () => { it('should return initial statistics', () => { const stats = getInstanceCacheMetrics(); expect(stats).toBeDefined(); expect(stats.hits).toBe(0); expect(stats.misses).toBe(0); expect(stats.size).toBe(0); expect(stats.avgHitRate).toBe(0); }); it('should track cache hits and misses', () => { const context1: InstanceContext = { n8nApiUrl: 'https://api1.n8n.cloud', n8nApiKey: 'key1', instanceId: 'instance1' }; const context2: InstanceContext = { n8nApiUrl: 'https://api2.n8n.cloud', n8nApiKey: 'key2', instanceId: 'instance2' }; // First access - cache miss getN8nApiClient(context1); let stats = getInstanceCacheMetrics(); expect(stats.misses).toBe(1); expect(stats.hits).toBe(0); expect(stats.size).toBe(1); // Second access same context - cache hit getN8nApiClient(context1); stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(1); expect(stats.misses).toBe(1); expect(stats.avgHitRate).toBe(0.5); // 1 hit / 2 total // Third access different context - cache miss getN8nApiClient(context2); stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(1); expect(stats.misses).toBe(2); expect(stats.size).toBe(2); expect(stats.avgHitRate).toBeCloseTo(0.333, 2); // 1 hit / 3 total }); it('should track evictions when cache is full', () => { // Note: Cache is created with default size (100), so we need many items to trigger evictions // This test verifies that eviction tracking works, even if we don't hit the limit in practice const initialStats = getInstanceCacheMetrics(); // The cache dispose callback should track evictions when items are removed // For this test, we'll verify the eviction tracking mechanism exists expect(initialStats.evictions).toBeGreaterThanOrEqual(0); // Add a few items to cache const contexts = [ { n8nApiUrl: 'https://api1.n8n.cloud', n8nApiKey: 'key1' }, { n8nApiUrl: 'https://api2.n8n.cloud', n8nApiKey: 'key2' }, { n8nApiUrl: 'https://api3.n8n.cloud', n8nApiKey: 'key3' } ]; contexts.forEach(ctx => getN8nApiClient(ctx)); const stats = getInstanceCacheMetrics(); expect(stats.size).toBe(3); // All items should fit in default cache (max: 100) }); it('should track cache operations over time', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key' }; // Simulate multiple operations for (let i = 0; i < 10; i++) { getN8nApiClient(context); } const stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(9); // First is miss, rest are hits expect(stats.misses).toBe(1); expect(stats.avgHitRate).toBe(0.9); // 9/10 expect(stats.sets).toBeGreaterThanOrEqual(1); }); it('should include timestamp information', () => { const stats = getInstanceCacheMetrics(); expect(stats.createdAt).toBeInstanceOf(Date); expect(stats.lastResetAt).toBeInstanceOf(Date); expect(stats.createdAt.getTime()).toBeLessThanOrEqual(Date.now()); }); it('should track cache clear operations', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key' }; // Add some clients getN8nApiClient(context); // Clear cache clearInstanceCache(); const stats = getInstanceCacheMetrics(); expect(stats.clears).toBe(1); expect(stats.size).toBe(0); }); }); describe('Cache Metrics with Different Scenarios', () => { it('should handle rapid successive requests', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'rapid-test' }; // Simulate rapid requests const promises = []; for (let i = 0; i < 50; i++) { promises.push(Promise.resolve(getN8nApiClient(context))); } return Promise.all(promises).then(() => { const stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(49); // First is miss expect(stats.misses).toBe(1); expect(stats.avgHitRate).toBe(0.98); // 49/50 }); }); it('should track metrics for fallback to environment variables', () => { // Note: Singleton mode (no context) doesn't use the instance cache // This test verifies that cache metrics are not affected by singleton usage const initialStats = getInstanceCacheMetrics(); process.env.N8N_API_URL = 'https://env.n8n.cloud'; process.env.N8N_API_KEY = 'env-key'; // Calls without context use singleton mode (no cache metrics) getN8nApiClient(); getN8nApiClient(); const stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(initialStats.hits); expect(stats.misses).toBe(initialStats.misses); }); it('should maintain separate metrics for different instances', () => { const contexts = Array.from({ length: 5 }, (_, i) => ({ n8nApiUrl: `https://api${i}.n8n.cloud`, n8nApiKey: `key${i}`, instanceId: `instance${i}` })); // Access each instance twice contexts.forEach(ctx => { getN8nApiClient(ctx); // Miss getN8nApiClient(ctx); // Hit }); const stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(5); expect(stats.misses).toBe(5); expect(stats.size).toBe(5); expect(stats.avgHitRate).toBe(0.5); }); it('should handle cache with TTL expiration', () => { // Note: TTL configuration is set when cache is created, not dynamically // This test verifies that TTL-related cache behavior can be tracked const context: InstanceContext = { n8nApiUrl: 'https://ttl-test.n8n.cloud', n8nApiKey: 'ttl-key' }; // First access - miss getN8nApiClient(context); // Second access - hit (within TTL) getN8nApiClient(context); const stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(1); expect(stats.misses).toBe(1); }); }); describe('getCacheStatistics (formatted)', () => { it('should return human-readable statistics', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key' }; // Generate some activity getN8nApiClient(context); getN8nApiClient(context); getN8nApiClient({ ...context, instanceId: 'different' }); const formattedStats = getCacheStatistics(); expect(formattedStats).toContain('Cache Statistics:'); expect(formattedStats).toContain('Runtime:'); expect(formattedStats).toContain('Total Operations:'); expect(formattedStats).toContain('Hit Rate:'); expect(formattedStats).toContain('Current Size:'); expect(formattedStats).toContain('Total Evictions:'); }); it('should show runtime in minutes', () => { const stats = getCacheStatistics(); expect(stats).toMatch(/Runtime: \d+ minutes/); }); it('should show operation counts', () => { const context: InstanceContext = { n8nApiUrl: 'https://api.n8n.cloud', n8nApiKey: 'test-key' }; // Generate operations getN8nApiClient(context); // Set getN8nApiClient(context); // Hit clearInstanceCache(); // Clear const stats = getCacheStatistics(); expect(stats).toContain('Sets: 1'); expect(stats).toContain('Clears: 1'); }); }); describe('Monitoring Performance Impact', () => { it('should have minimal performance overhead', () => { const context: InstanceContext = { n8nApiUrl: 'https://perf-test.n8n.cloud', n8nApiKey: 'perf-key' }; const startTime = performance.now(); // Perform many operations for (let i = 0; i < 1000; i++) { getN8nApiClient(context); } const endTime = performance.now(); const totalTime = endTime - startTime; // Should complete quickly (< 100ms for 1000 operations) expect(totalTime).toBeLessThan(100); // Verify metrics were tracked const stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(999); expect(stats.misses).toBe(1); }); it('should handle concurrent metric updates', async () => { const contexts = Array.from({ length: 10 }, (_, i) => ({ n8nApiUrl: `https://concurrent${i}.n8n.cloud`, n8nApiKey: `key${i}` })); // Concurrent requests const promises = contexts.map(ctx => Promise.resolve(getN8nApiClient(ctx)) ); await Promise.all(promises); const stats = getInstanceCacheMetrics(); expect(stats.misses).toBe(10); expect(stats.size).toBe(10); }); }); describe('Edge Cases and Error Conditions', () => { it('should handle metrics when cache operations fail', () => { const invalidContext = { n8nApiUrl: '', n8nApiKey: '' } as InstanceContext; // This should fail validation but metrics should still work const client = getN8nApiClient(invalidContext); expect(client).toBeNull(); // Metrics should not be affected by validation failures const stats = getInstanceCacheMetrics(); expect(stats).toBeDefined(); }); it('should maintain metrics integrity after reset', () => { const context: InstanceContext = { n8nApiUrl: 'https://reset-test.n8n.cloud', n8nApiKey: 'reset-key' }; // Generate some metrics getN8nApiClient(context); getN8nApiClient(context); // Reset metrics cacheMetrics.reset(); // New operations should start fresh getN8nApiClient(context); const stats = getInstanceCacheMetrics(); expect(stats.hits).toBe(1); // Cache still has item from before reset expect(stats.misses).toBe(0); expect(stats.lastResetAt.getTime()).toBeGreaterThan(stats.createdAt.getTime()); }); it('should handle maximum cache size correctly', () => { // Note: Cache uses default configuration (max: 100) since it's created at module load const contexts = Array.from({ length: 5 }, (_, i) => ({ n8nApiUrl: `https://max${i}.n8n.cloud`, n8nApiKey: `key${i}` })); // Add items within default cache size contexts.forEach(ctx => getN8nApiClient(ctx)); const stats = getInstanceCacheMetrics(); expect(stats.size).toBe(5); // Should fit in default cache expect(stats.maxSize).toBe(100); // Default max size }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/database/node-repository-core.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { NodeRepository } from '../../../src/database/node-repository'; import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter'; import { ParsedNode } from '../../../src/parsers/node-parser'; // Create a complete mock for DatabaseAdapter class MockDatabaseAdapter implements DatabaseAdapter { private statements = new Map<string, MockPreparedStatement>(); private mockData = new Map<string, any>(); prepare = vi.fn((sql: string) => { if (!this.statements.has(sql)) { this.statements.set(sql, new MockPreparedStatement(sql, this.mockData)); } return this.statements.get(sql)!; }); exec = vi.fn(); close = vi.fn(); pragma = vi.fn(); transaction = vi.fn((fn: () => any) => fn()); checkFTS5Support = vi.fn(() => true); inTransaction = false; // Test helper to set mock data _setMockData(key: string, value: any) { this.mockData.set(key, value); } // Test helper to get statement by SQL _getStatement(sql: string) { return this.statements.get(sql); } } class MockPreparedStatement implements PreparedStatement { run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 })); get = vi.fn(); all = vi.fn(() => []); iterate = vi.fn(); pluck = vi.fn(() => this); expand = vi.fn(() => this); raw = vi.fn(() => this); columns = vi.fn(() => []); bind = vi.fn(() => this); constructor(private sql: string, private mockData: Map<string, any>) { // Configure get() based on SQL pattern if (sql.includes('SELECT * FROM nodes WHERE node_type = ?')) { this.get = vi.fn((nodeType: string) => this.mockData.get(`node:${nodeType}`)); } // Configure all() for getAITools if (sql.includes('WHERE is_ai_tool = 1')) { this.all = vi.fn(() => this.mockData.get('ai_tools') || []); } } } describe('NodeRepository - Core Functionality', () => { let repository: NodeRepository; let mockAdapter: MockDatabaseAdapter; beforeEach(() => { mockAdapter = new MockDatabaseAdapter(); repository = new NodeRepository(mockAdapter); }); describe('saveNode', () => { it('should save a node with proper JSON serialization', () => { const parsedNode: ParsedNode = { nodeType: 'nodes-base.httpRequest', displayName: 'HTTP Request', description: 'Makes HTTP requests', category: 'transform', style: 'declarative', packageName: 'n8n-nodes-base', properties: [{ name: 'url', type: 'string' }], operations: [{ name: 'execute', displayName: 'Execute' }], credentials: [{ name: 'httpBasicAuth' }], isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1.0', documentation: 'HTTP Request documentation', outputs: undefined, outputNames: undefined }; repository.saveNode(parsedNode); // Verify prepare was called with correct SQL expect(mockAdapter.prepare).toHaveBeenCalledWith(expect.stringContaining('INSERT OR REPLACE INTO nodes')); // Get the prepared statement and verify run was called const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || ''); expect(stmt?.run).toHaveBeenCalledWith( 'nodes-base.httpRequest', 'n8n-nodes-base', 'HTTP Request', 'Makes HTTP requests', 'transform', 'declarative', 0, // isAITool 0, // isTrigger 0, // isWebhook 1, // isVersioned '1.0', 'HTTP Request documentation', JSON.stringify([{ name: 'url', type: 'string' }], null, 2), JSON.stringify([{ name: 'execute', displayName: 'Execute' }], null, 2), JSON.stringify([{ name: 'httpBasicAuth' }], null, 2), null, // outputs null // outputNames ); }); it('should handle nodes without optional fields', () => { const minimalNode: ParsedNode = { nodeType: 'nodes-base.simple', displayName: 'Simple Node', category: 'core', style: 'programmatic', packageName: 'n8n-nodes-base', properties: [], operations: [], credentials: [], isAITool: true, isTrigger: true, isWebhook: true, isVersioned: false, outputs: undefined, outputNames: undefined }; repository.saveNode(minimalNode); const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || ''); const runCall = stmt?.run.mock.lastCall; expect(runCall?.[2]).toBe('Simple Node'); // displayName expect(runCall?.[3]).toBeUndefined(); // description expect(runCall?.[10]).toBeUndefined(); // version expect(runCall?.[11]).toBeNull(); // documentation }); }); describe('getNode', () => { it('should retrieve and deserialize a node correctly', () => { const mockRow = { node_type: 'nodes-base.httpRequest', display_name: 'HTTP Request', description: 'Makes HTTP requests', category: 'transform', development_style: 'declarative', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 1, version: '1.0', properties_schema: JSON.stringify([{ name: 'url', type: 'string' }]), operations: JSON.stringify([{ name: 'execute' }]), credentials_required: JSON.stringify([{ name: 'httpBasicAuth' }]), documentation: 'HTTP docs', outputs: null, output_names: null }; mockAdapter._setMockData('node:nodes-base.httpRequest', mockRow); const result = repository.getNode('nodes-base.httpRequest'); expect(result).toEqual({ nodeType: 'nodes-base.httpRequest', displayName: 'HTTP Request', description: 'Makes HTTP requests', category: 'transform', developmentStyle: 'declarative', package: 'n8n-nodes-base', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1.0', properties: [{ name: 'url', type: 'string' }], operations: [{ name: 'execute' }], credentials: [{ name: 'httpBasicAuth' }], hasDocumentation: true, outputs: null, outputNames: null }); }); it('should return null for non-existent nodes', () => { const result = repository.getNode('non-existent'); expect(result).toBeNull(); }); it('should handle invalid JSON gracefully', () => { const mockRow = { node_type: 'nodes-base.broken', display_name: 'Broken Node', description: 'Node with broken JSON', category: 'transform', development_style: 'declarative', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: null, properties_schema: '{invalid json', operations: 'not json at all', credentials_required: '{"valid": "json"}', documentation: null, outputs: null, output_names: null }; mockAdapter._setMockData('node:nodes-base.broken', mockRow); const result = repository.getNode('nodes-base.broken'); expect(result?.properties).toEqual([]); // defaultValue from safeJsonParse expect(result?.operations).toEqual([]); // defaultValue from safeJsonParse expect(result?.credentials).toEqual({ valid: 'json' }); // successfully parsed }); }); describe('getAITools', () => { it('should retrieve all AI tools sorted by display name', () => { const mockAITools = [ { node_type: 'nodes-base.openai', display_name: 'OpenAI', description: 'OpenAI integration', package_name: 'n8n-nodes-base' }, { node_type: 'nodes-base.agent', display_name: 'AI Agent', description: 'AI Agent node', package_name: '@n8n/n8n-nodes-langchain' } ]; mockAdapter._setMockData('ai_tools', mockAITools); const result = repository.getAITools(); expect(result).toEqual([ { nodeType: 'nodes-base.openai', displayName: 'OpenAI', description: 'OpenAI integration', package: 'n8n-nodes-base' }, { nodeType: 'nodes-base.agent', displayName: 'AI Agent', description: 'AI Agent node', package: '@n8n/n8n-nodes-langchain' } ]); }); it('should return empty array when no AI tools exist', () => { mockAdapter._setMockData('ai_tools', []); const result = repository.getAITools(); expect(result).toEqual([]); }); }); describe('safeJsonParse', () => { it('should parse valid JSON', () => { // Access private method through the class const parseMethod = (repository as any).safeJsonParse.bind(repository); const validJson = '{"key": "value", "number": 42}'; const result = parseMethod(validJson, {}); expect(result).toEqual({ key: 'value', number: 42 }); }); it('should return default value for invalid JSON', () => { const parseMethod = (repository as any).safeJsonParse.bind(repository); const invalidJson = '{invalid json}'; const defaultValue = { default: true }; const result = parseMethod(invalidJson, defaultValue); expect(result).toEqual(defaultValue); }); it('should handle empty strings', () => { const parseMethod = (repository as any).safeJsonParse.bind(repository); const result = parseMethod('', []); expect(result).toEqual([]); }); it('should handle null and undefined', () => { const parseMethod = (repository as any).safeJsonParse.bind(repository); // JSON.parse(null) returns null, not an error expect(parseMethod(null, 'default')).toBe(null); expect(parseMethod(undefined, 'default')).toBe('default'); }); }); describe('Edge Cases', () => { it('should handle very large JSON properties', () => { const largeProperties = Array(1000).fill(null).map((_, i) => ({ name: `prop${i}`, type: 'string', description: 'A'.repeat(100) })); const node: ParsedNode = { nodeType: 'nodes-base.large', displayName: 'Large Node', category: 'test', style: 'declarative', packageName: 'test', properties: largeProperties, operations: [], credentials: [], isAITool: false, isTrigger: false, isWebhook: false, isVersioned: false, outputs: undefined, outputNames: undefined }; repository.saveNode(node); const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || ''); const runCall = stmt?.run.mock.lastCall; const savedProperties = runCall?.[12]; expect(savedProperties).toBe(JSON.stringify(largeProperties, null, 2)); }); it('should handle boolean conversion for integer fields', () => { const mockRow = { node_type: 'nodes-base.bool-test', display_name: 'Bool Test', description: 'Testing boolean conversion', category: 'test', development_style: 'declarative', package_name: 'test', is_ai_tool: 1, is_trigger: 0, is_webhook: '1', // String that should be converted is_versioned: '0', // String that should be converted version: null, properties_schema: '[]', operations: '[]', credentials_required: '[]', documentation: null, outputs: null, output_names: null }; mockAdapter._setMockData('node:nodes-base.bool-test', mockRow); const result = repository.getNode('nodes-base.bool-test'); expect(result?.isAITool).toBe(true); expect(result?.isTrigger).toBe(false); expect(result?.isWebhook).toBe(true); expect(result?.isVersioned).toBe(false); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/ai-validation/ai-tool-validation.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Tests: AI Tool Validation * * Tests AI tool node validation against real n8n instance. * Covers HTTP Request Tool, Code Tool, Vector Store Tool, Workflow Tool, Calculator Tool. */ import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { createTestContext, TestContext, createTestWorkflowName } from '../n8n-api/utils/test-context'; import { getTestN8nClient } from '../n8n-api/utils/n8n-client'; import { N8nApiClient } from '../../../src/services/n8n-api-client'; import { cleanupOrphanedWorkflows } from '../n8n-api/utils/cleanup-helpers'; import { createMcpContext } from '../n8n-api/utils/mcp-context'; import { InstanceContext } from '../../../src/types/instance-context'; import { handleValidateWorkflow } from '../../../src/mcp/handlers-n8n-manager'; import { getNodeRepository, closeNodeRepository } from '../n8n-api/utils/node-repository'; import { NodeRepository } from '../../../src/database/node-repository'; import { ValidationResponse } from '../n8n-api/types/mcp-responses'; import { createHTTPRequestToolNode, createCodeToolNode, createVectorStoreToolNode, createWorkflowToolNode, createCalculatorToolNode, createAIWorkflow } from './helpers'; describe('Integration: AI Tool Validation', () => { let context: TestContext; let client: N8nApiClient; let mcpContext: InstanceContext; let repository: NodeRepository; beforeEach(async () => { context = createTestContext(); client = getTestN8nClient(); mcpContext = createMcpContext(); repository = await getNodeRepository(); }); afterEach(async () => { await context.cleanup(); }); afterAll(async () => { await closeNodeRepository(); if (!process.env.CI) { await cleanupOrphanedWorkflows(); } }); // ====================================================================== // HTTP Request Tool Tests // ====================================================================== describe('HTTP Request Tool', () => { it('should detect missing toolDescription', async () => { const httpTool = createHTTPRequestToolNode({ name: 'HTTP Request Tool', toolDescription: '', // Missing url: 'https://api.example.com/data', method: 'GET' }); const workflow = createAIWorkflow( [httpTool], {}, { name: createTestWorkflowName('HTTP Tool - No Description'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MISSING_TOOL_DESCRIPTION'); }); it('should detect missing URL', async () => { const httpTool = createHTTPRequestToolNode({ name: 'HTTP Request Tool', toolDescription: 'Fetches data from API', url: '', // Missing method: 'GET' }); const workflow = createAIWorkflow( [httpTool], {}, { name: createTestWorkflowName('HTTP Tool - No URL'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MISSING_URL'); }); it('should validate valid HTTP Request Tool', async () => { const httpTool = createHTTPRequestToolNode({ name: 'HTTP Request Tool', toolDescription: 'Fetches weather data from the weather API', url: 'https://api.weather.com/current', method: 'GET' }); const workflow = createAIWorkflow( [httpTool], {}, { name: createTestWorkflowName('HTTP Tool - Valid'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(true); expect(data.errors).toBeUndefined(); }); }); // ====================================================================== // Code Tool Tests // ====================================================================== describe('Code Tool', () => { it('should detect missing code', async () => { const codeTool = createCodeToolNode({ name: 'Code Tool', toolDescription: 'Processes data with custom logic', code: '' // Missing }); const workflow = createAIWorkflow( [codeTool], {}, { name: createTestWorkflowName('Code Tool - No Code'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MISSING_CODE'); }); it('should validate valid Code Tool', async () => { const codeTool = createCodeToolNode({ name: 'Code Tool', toolDescription: 'Calculates the sum of two numbers', code: 'return { sum: Number(a) + Number(b) };' }); const workflow = createAIWorkflow( [codeTool], {}, { name: createTestWorkflowName('Code Tool - Valid'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(true); expect(data.errors).toBeUndefined(); }); }); // ====================================================================== // Vector Store Tool Tests // ====================================================================== describe('Vector Store Tool', () => { it('should detect missing toolDescription', async () => { const vectorTool = createVectorStoreToolNode({ name: 'Vector Store Tool', toolDescription: '' // Missing }); const workflow = createAIWorkflow( [vectorTool], {}, { name: createTestWorkflowName('Vector Tool - No Description'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MISSING_TOOL_DESCRIPTION'); }); it('should validate valid Vector Store Tool', async () => { const vectorTool = createVectorStoreToolNode({ name: 'Vector Store Tool', toolDescription: 'Searches documentation in vector database' }); const workflow = createAIWorkflow( [vectorTool], {}, { name: createTestWorkflowName('Vector Tool - Valid'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(true); expect(data.errors).toBeUndefined(); }); }); // ====================================================================== // Workflow Tool Tests // ====================================================================== describe('Workflow Tool', () => { it('should detect missing workflowId', async () => { const workflowTool = createWorkflowToolNode({ name: 'Workflow Tool', toolDescription: 'Executes a sub-workflow', workflowId: '' // Missing }); const workflow = createAIWorkflow( [workflowTool], {}, { name: createTestWorkflowName('Workflow Tool - No ID'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(false); expect(data.errors).toBeDefined(); const errorCodes = data.errors!.map(e => e.details?.code || e.code); expect(errorCodes).toContain('MISSING_WORKFLOW_ID'); }); it('should validate valid Workflow Tool', async () => { const workflowTool = createWorkflowToolNode({ name: 'Workflow Tool', toolDescription: 'Processes customer data through validation workflow', workflowId: '123' }); const workflow = createAIWorkflow( [workflowTool], {}, { name: createTestWorkflowName('Workflow Tool - Valid'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; expect(data.valid).toBe(true); expect(data.errors).toBeUndefined(); }); }); // ====================================================================== // Calculator Tool Tests // ====================================================================== describe('Calculator Tool', () => { it('should validate Calculator Tool (no configuration needed)', async () => { const calcTool = createCalculatorToolNode({ name: 'Calculator' }); const workflow = createAIWorkflow( [calcTool], {}, { name: createTestWorkflowName('Calculator Tool - Valid'), tags: ['mcp-integration-test', 'ai-validation'] } ); const created = await client.createWorkflow(workflow); context.trackWorkflow(created.id!); const response = await handleValidateWorkflow( { id: created.id }, repository, mcpContext ); expect(response.success).toBe(true); const data = response.data as ValidationResponse; // Calculator has no required configuration expect(data.valid).toBe(true); expect(data.errors).toBeUndefined(); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-auto-fixer.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { WorkflowAutoFixer, isNodeFormatIssue } from '@/services/workflow-auto-fixer'; import { NodeRepository } from '@/database/node-repository'; import type { WorkflowValidationResult } from '@/services/workflow-validator'; import type { ExpressionFormatIssue } from '@/services/expression-format-validator'; import type { Workflow, WorkflowNode } from '@/types/n8n-api'; vi.mock('@/database/node-repository'); vi.mock('@/services/node-similarity-service'); describe('WorkflowAutoFixer', () => { let autoFixer: WorkflowAutoFixer; let mockRepository: NodeRepository; const createMockWorkflow = (nodes: WorkflowNode[]): Workflow => ({ id: 'test-workflow', name: 'Test Workflow', active: false, nodes, connections: {}, settings: {}, createdAt: '', updatedAt: '' }); const createMockNode = (id: string, type: string, parameters: any = {}): WorkflowNode => ({ id, name: id, type, typeVersion: 1, position: [0, 0], parameters }); beforeEach(() => { vi.clearAllMocks(); mockRepository = new NodeRepository({} as any); autoFixer = new WorkflowAutoFixer(mockRepository); }); describe('Type Guards', () => { it('should identify NodeFormatIssue correctly', () => { const validIssue: ExpressionFormatIssue = { fieldPath: 'url', currentValue: '{{ $json.url }}', correctedValue: '={{ $json.url }}', issueType: 'missing-prefix', severity: 'error', explanation: 'Missing = prefix' } as any; (validIssue as any).nodeName = 'httpRequest'; (validIssue as any).nodeId = 'node-1'; const invalidIssue: ExpressionFormatIssue = { fieldPath: 'url', currentValue: '{{ $json.url }}', correctedValue: '={{ $json.url }}', issueType: 'missing-prefix', severity: 'error', explanation: 'Missing = prefix' }; expect(isNodeFormatIssue(validIssue)).toBe(true); expect(isNodeFormatIssue(invalidIssue)).toBe(false); }); }); describe('Expression Format Fixes', () => { it('should fix missing prefix in expressions', () => { const workflow = createMockWorkflow([ createMockNode('node-1', 'nodes-base.httpRequest', { url: '{{ $json.url }}', method: 'GET' }) ]); const formatIssues: ExpressionFormatIssue[] = [{ fieldPath: 'url', currentValue: '{{ $json.url }}', correctedValue: '={{ $json.url }}', issueType: 'missing-prefix', severity: 'error', explanation: 'Expression must start with =', nodeName: 'node-1', nodeId: 'node-1' } as any]; const validationResult: WorkflowValidationResult = { valid: false, errors: [], warnings: [], statistics: { totalNodes: 1, enabledNodes: 1, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0 }, suggestions: [] }; const result = autoFixer.generateFixes(workflow, validationResult, formatIssues); expect(result.fixes).toHaveLength(1); expect(result.fixes[0].type).toBe('expression-format'); expect(result.fixes[0].before).toBe('{{ $json.url }}'); expect(result.fixes[0].after).toBe('={{ $json.url }}'); expect(result.fixes[0].confidence).toBe('high'); expect(result.operations).toHaveLength(1); expect(result.operations[0].type).toBe('updateNode'); }); it('should handle multiple expression fixes in same node', () => { const workflow = createMockWorkflow([ createMockNode('node-1', 'nodes-base.httpRequest', { url: '{{ $json.url }}', body: '{{ $json.body }}' }) ]); const formatIssues: ExpressionFormatIssue[] = [ { fieldPath: 'url', currentValue: '{{ $json.url }}', correctedValue: '={{ $json.url }}', issueType: 'missing-prefix', severity: 'error', explanation: 'Expression must start with =', nodeName: 'node-1', nodeId: 'node-1' } as any, { fieldPath: 'body', currentValue: '{{ $json.body }}', correctedValue: '={{ $json.body }}', issueType: 'missing-prefix', severity: 'error', explanation: 'Expression must start with =', nodeName: 'node-1', nodeId: 'node-1' } as any ]; const validationResult: WorkflowValidationResult = { valid: false, errors: [], warnings: [], statistics: { totalNodes: 1, enabledNodes: 1, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0 }, suggestions: [] }; const result = autoFixer.generateFixes(workflow, validationResult, formatIssues); expect(result.fixes).toHaveLength(2); expect(result.operations).toHaveLength(1); // Single update operation for the node }); }); describe('TypeVersion Fixes', () => { it('should fix typeVersion exceeding maximum', () => { const workflow = createMockWorkflow([ createMockNode('node-1', 'nodes-base.httpRequest', {}) ]); const validationResult: WorkflowValidationResult = { valid: false, errors: [{ type: 'error', nodeId: 'node-1', nodeName: 'node-1', message: 'typeVersion 3.5 exceeds maximum supported version 2.0' }], warnings: [], statistics: { totalNodes: 1, enabledNodes: 1, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0 }, suggestions: [] }; const result = autoFixer.generateFixes(workflow, validationResult, []); expect(result.fixes).toHaveLength(1); expect(result.fixes[0].type).toBe('typeversion-correction'); expect(result.fixes[0].before).toBe(3.5); expect(result.fixes[0].after).toBe(2); expect(result.fixes[0].confidence).toBe('medium'); }); }); describe('Error Output Configuration Fixes', () => { it('should remove conflicting onError setting', () => { const workflow = createMockWorkflow([ createMockNode('node-1', 'nodes-base.httpRequest', {}) ]); workflow.nodes[0].onError = 'continueErrorOutput'; const validationResult: WorkflowValidationResult = { valid: false, errors: [{ type: 'error', nodeId: 'node-1', nodeName: 'node-1', message: "Node has onError: 'continueErrorOutput' but no error output connections" }], warnings: [], statistics: { totalNodes: 1, enabledNodes: 1, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0 }, suggestions: [] }; const result = autoFixer.generateFixes(workflow, validationResult, []); expect(result.fixes).toHaveLength(1); expect(result.fixes[0].type).toBe('error-output-config'); expect(result.fixes[0].before).toBe('continueErrorOutput'); expect(result.fixes[0].after).toBeUndefined(); expect(result.fixes[0].confidence).toBe('medium'); }); }); describe('setNestedValue Validation', () => { it('should throw error for non-object target', () => { expect(() => { autoFixer['setNestedValue'](null, ['field'], 'value'); }).toThrow('Cannot set value on non-object'); expect(() => { autoFixer['setNestedValue']('string', ['field'], 'value'); }).toThrow('Cannot set value on non-object'); }); it('should throw error for empty path', () => { expect(() => { autoFixer['setNestedValue']({}, [], 'value'); }).toThrow('Cannot set value with empty path'); }); it('should handle nested paths correctly', () => { const obj = { level1: { level2: { level3: 'old' } } }; autoFixer['setNestedValue'](obj, ['level1', 'level2', 'level3'], 'new'); expect(obj.level1.level2.level3).toBe('new'); }); it('should create missing nested objects', () => { const obj = {}; autoFixer['setNestedValue'](obj, ['level1', 'level2', 'level3'], 'value'); expect(obj).toEqual({ level1: { level2: { level3: 'value' } } }); }); it('should handle array indices in paths', () => { const obj: any = { items: [] }; autoFixer['setNestedValue'](obj, ['items[0]', 'name'], 'test'); expect(obj.items[0].name).toBe('test'); }); it('should throw error for invalid array notation', () => { const obj = {}; expect(() => { autoFixer['setNestedValue'](obj, ['field[abc]'], 'value'); }).toThrow('Invalid array notation: field[abc]'); }); it('should throw when trying to traverse non-object', () => { const obj = { field: 'string' }; expect(() => { autoFixer['setNestedValue'](obj, ['field', 'nested'], 'value'); }).toThrow('Cannot traverse through string at field'); }); }); describe('Confidence Filtering', () => { it('should filter fixes by confidence level', () => { const workflow = createMockWorkflow([ createMockNode('node-1', 'nodes-base.httpRequest', { url: '{{ $json.url }}' }) ]); const formatIssues: ExpressionFormatIssue[] = [{ fieldPath: 'url', currentValue: '{{ $json.url }}', correctedValue: '={{ $json.url }}', issueType: 'missing-prefix', severity: 'error', explanation: 'Expression must start with =', nodeName: 'node-1', nodeId: 'node-1' } as any]; const validationResult: WorkflowValidationResult = { valid: false, errors: [], warnings: [], statistics: { totalNodes: 1, enabledNodes: 1, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0 }, suggestions: [] }; const result = autoFixer.generateFixes(workflow, validationResult, formatIssues, { confidenceThreshold: 'low' }); expect(result.fixes.length).toBeGreaterThan(0); expect(result.fixes.every(f => ['high', 'medium', 'low'].includes(f.confidence))).toBe(true); }); }); describe('Summary Generation', () => { it('should generate appropriate summary for fixes', () => { const workflow = createMockWorkflow([ createMockNode('node-1', 'nodes-base.httpRequest', { url: '{{ $json.url }}' }) ]); const formatIssues: ExpressionFormatIssue[] = [{ fieldPath: 'url', currentValue: '{{ $json.url }}', correctedValue: '={{ $json.url }}', issueType: 'missing-prefix', severity: 'error', explanation: 'Expression must start with =', nodeName: 'node-1', nodeId: 'node-1' } as any]; const validationResult: WorkflowValidationResult = { valid: false, errors: [], warnings: [], statistics: { totalNodes: 1, enabledNodes: 1, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0 }, suggestions: [] }; const result = autoFixer.generateFixes(workflow, validationResult, formatIssues); expect(result.summary).toContain('expression format'); expect(result.stats.total).toBe(1); expect(result.stats.byType['expression-format']).toBe(1); }); it('should handle empty fixes gracefully', () => { const workflow = createMockWorkflow([]); const validationResult: WorkflowValidationResult = { valid: true, errors: [], warnings: [], statistics: { totalNodes: 0, enabledNodes: 0, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0 }, suggestions: [] }; const result = autoFixer.generateFixes(workflow, validationResult, []); expect(result.summary).toBe('No fixes available'); expect(result.stats.total).toBe(0); expect(result.operations).toEqual([]); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/utils/node-type-normalizer.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for NodeTypeNormalizer * * Comprehensive test suite for the node type normalization utility * that fixes the critical issue of AI agents producing short-form node types */ import { describe, it, expect } from 'vitest'; import { NodeTypeNormalizer } from '../../../src/utils/node-type-normalizer'; describe('NodeTypeNormalizer', () => { describe('normalizeToFullForm', () => { describe('Base nodes', () => { it('should normalize full base form to short form', () => { expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.webhook')) .toBe('nodes-base.webhook'); }); it('should normalize full base form with different node names', () => { expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.httpRequest')) .toBe('nodes-base.httpRequest'); expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.set')) .toBe('nodes-base.set'); expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.slack')) .toBe('nodes-base.slack'); }); it('should leave short base form unchanged', () => { expect(NodeTypeNormalizer.normalizeToFullForm('nodes-base.webhook')) .toBe('nodes-base.webhook'); expect(NodeTypeNormalizer.normalizeToFullForm('nodes-base.httpRequest')) .toBe('nodes-base.httpRequest'); }); }); describe('LangChain nodes', () => { it('should normalize full langchain form to short form', () => { expect(NodeTypeNormalizer.normalizeToFullForm('@n8n/n8n-nodes-langchain.agent')) .toBe('nodes-langchain.agent'); expect(NodeTypeNormalizer.normalizeToFullForm('@n8n/n8n-nodes-langchain.openAi')) .toBe('nodes-langchain.openAi'); }); it('should normalize langchain form with n8n- prefix but missing @n8n/', () => { expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-langchain.agent')) .toBe('nodes-langchain.agent'); }); it('should leave short langchain form unchanged', () => { expect(NodeTypeNormalizer.normalizeToFullForm('nodes-langchain.agent')) .toBe('nodes-langchain.agent'); expect(NodeTypeNormalizer.normalizeToFullForm('nodes-langchain.openAi')) .toBe('nodes-langchain.openAi'); }); }); describe('Edge cases', () => { it('should handle empty string', () => { expect(NodeTypeNormalizer.normalizeToFullForm('')).toBe(''); }); it('should handle null', () => { expect(NodeTypeNormalizer.normalizeToFullForm(null as any)).toBe(null); }); it('should handle undefined', () => { expect(NodeTypeNormalizer.normalizeToFullForm(undefined as any)).toBe(undefined); }); it('should handle non-string input', () => { expect(NodeTypeNormalizer.normalizeToFullForm(123 as any)).toBe(123); expect(NodeTypeNormalizer.normalizeToFullForm({} as any)).toEqual({}); }); it('should leave community nodes unchanged', () => { expect(NodeTypeNormalizer.normalizeToFullForm('custom-package.myNode')) .toBe('custom-package.myNode'); }); it('should leave nodes without prefixes unchanged', () => { expect(NodeTypeNormalizer.normalizeToFullForm('someRandomNode')) .toBe('someRandomNode'); }); }); }); describe('normalizeWithDetails', () => { it('should return normalization details for full base form', () => { const result = NodeTypeNormalizer.normalizeWithDetails('n8n-nodes-base.webhook'); expect(result).toEqual({ original: 'n8n-nodes-base.webhook', normalized: 'nodes-base.webhook', wasNormalized: true, package: 'base' }); }); it('should return normalization details for already short form', () => { const result = NodeTypeNormalizer.normalizeWithDetails('nodes-base.webhook'); expect(result).toEqual({ original: 'nodes-base.webhook', normalized: 'nodes-base.webhook', wasNormalized: false, package: 'base' }); }); it('should detect langchain package', () => { const result = NodeTypeNormalizer.normalizeWithDetails('@n8n/n8n-nodes-langchain.agent'); expect(result).toEqual({ original: '@n8n/n8n-nodes-langchain.agent', normalized: 'nodes-langchain.agent', wasNormalized: true, package: 'langchain' }); }); it('should detect community package', () => { const result = NodeTypeNormalizer.normalizeWithDetails('custom-package.myNode'); expect(result).toEqual({ original: 'custom-package.myNode', normalized: 'custom-package.myNode', wasNormalized: false, package: 'community' }); }); it('should detect unknown package', () => { const result = NodeTypeNormalizer.normalizeWithDetails('unknownNode'); expect(result).toEqual({ original: 'unknownNode', normalized: 'unknownNode', wasNormalized: false, package: 'unknown' }); }); }); describe('normalizeBatch', () => { it('should normalize multiple node types', () => { const types = ['n8n-nodes-base.webhook', 'n8n-nodes-base.set', '@n8n/n8n-nodes-langchain.agent']; const result = NodeTypeNormalizer.normalizeBatch(types); expect(result.size).toBe(3); expect(result.get('n8n-nodes-base.webhook')).toBe('nodes-base.webhook'); expect(result.get('n8n-nodes-base.set')).toBe('nodes-base.set'); expect(result.get('@n8n/n8n-nodes-langchain.agent')).toBe('nodes-langchain.agent'); }); it('should handle empty array', () => { const result = NodeTypeNormalizer.normalizeBatch([]); expect(result.size).toBe(0); }); it('should handle mixed forms', () => { const types = [ 'n8n-nodes-base.webhook', 'nodes-base.set', '@n8n/n8n-nodes-langchain.agent', 'nodes-langchain.openAi' ]; const result = NodeTypeNormalizer.normalizeBatch(types); expect(result.size).toBe(4); expect(result.get('n8n-nodes-base.webhook')).toBe('nodes-base.webhook'); expect(result.get('nodes-base.set')).toBe('nodes-base.set'); expect(result.get('@n8n/n8n-nodes-langchain.agent')).toBe('nodes-langchain.agent'); expect(result.get('nodes-langchain.openAi')).toBe('nodes-langchain.openAi'); }); }); describe('normalizeWorkflowNodeTypes', () => { it('should normalize all nodes in workflow', () => { const workflow = { nodes: [ { type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] }, { type: 'n8n-nodes-base.set', id: '2', name: 'Set', parameters: {}, position: [100, 100] } ], connections: {} }; const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); expect(result.nodes[0].type).toBe('nodes-base.webhook'); expect(result.nodes[1].type).toBe('nodes-base.set'); }); it('should preserve all other node properties', () => { const workflow = { nodes: [ { type: 'n8n-nodes-base.webhook', id: 'test-id', name: 'Test Webhook', parameters: { path: '/test' }, position: [250, 300], credentials: { webhookAuth: { id: '1', name: 'Test' } } } ], connections: {} }; const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); expect(result.nodes[0]).toEqual({ type: 'nodes-base.webhook', // normalized to short form id: 'test-id', // preserved name: 'Test Webhook', // preserved parameters: { path: '/test' }, // preserved position: [250, 300], // preserved credentials: { webhookAuth: { id: '1', name: 'Test' } } // preserved }); }); it('should preserve workflow properties', () => { const workflow = { name: 'Test Workflow', active: true, nodes: [ { type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] } ], connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } } }; const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); expect(result.name).toBe('Test Workflow'); expect(result.active).toBe(true); expect(result.connections).toEqual({ '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } }); }); it('should handle workflow without nodes', () => { const workflow = { connections: {} }; const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); expect(result).toEqual(workflow); }); it('should handle null workflow', () => { const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(null); expect(result).toBe(null); }); it('should handle workflow with empty nodes array', () => { const workflow = { nodes: [], connections: {} }; const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); expect(result.nodes).toEqual([]); }); }); describe('isFullForm', () => { it('should return true for full base form', () => { expect(NodeTypeNormalizer.isFullForm('n8n-nodes-base.webhook')).toBe(true); }); it('should return true for full langchain form', () => { expect(NodeTypeNormalizer.isFullForm('@n8n/n8n-nodes-langchain.agent')).toBe(true); expect(NodeTypeNormalizer.isFullForm('n8n-nodes-langchain.agent')).toBe(true); }); it('should return false for short base form', () => { expect(NodeTypeNormalizer.isFullForm('nodes-base.webhook')).toBe(false); }); it('should return false for short langchain form', () => { expect(NodeTypeNormalizer.isFullForm('nodes-langchain.agent')).toBe(false); }); it('should return false for community nodes', () => { expect(NodeTypeNormalizer.isFullForm('custom-package.myNode')).toBe(false); }); it('should return false for null/undefined', () => { expect(NodeTypeNormalizer.isFullForm(null as any)).toBe(false); expect(NodeTypeNormalizer.isFullForm(undefined as any)).toBe(false); }); }); describe('isShortForm', () => { it('should return true for short base form', () => { expect(NodeTypeNormalizer.isShortForm('nodes-base.webhook')).toBe(true); }); it('should return true for short langchain form', () => { expect(NodeTypeNormalizer.isShortForm('nodes-langchain.agent')).toBe(true); }); it('should return false for full base form', () => { expect(NodeTypeNormalizer.isShortForm('n8n-nodes-base.webhook')).toBe(false); }); it('should return false for full langchain form', () => { expect(NodeTypeNormalizer.isShortForm('@n8n/n8n-nodes-langchain.agent')).toBe(false); expect(NodeTypeNormalizer.isShortForm('n8n-nodes-langchain.agent')).toBe(false); }); it('should return false for community nodes', () => { expect(NodeTypeNormalizer.isShortForm('custom-package.myNode')).toBe(false); }); it('should return false for null/undefined', () => { expect(NodeTypeNormalizer.isShortForm(null as any)).toBe(false); expect(NodeTypeNormalizer.isShortForm(undefined as any)).toBe(false); }); }); describe('Integration scenarios', () => { it('should handle the critical use case from P0-R1', () => { // This is the exact scenario - normalize full form to match database const fullFormType = 'n8n-nodes-base.webhook'; // External source produces this const normalized = NodeTypeNormalizer.normalizeToFullForm(fullFormType); expect(normalized).toBe('nodes-base.webhook'); // Database stores in short form }); it('should work correctly in a workflow validation scenario', () => { const workflow = { nodes: [ { type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] }, { type: 'n8n-nodes-base.httpRequest', id: '2', name: 'HTTP', parameters: {}, position: [200, 0] }, { type: 'nodes-base.set', id: '3', name: 'Set', parameters: {}, position: [400, 0] } ], connections: {} }; const normalized = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow); // All node types should now be in short form for database lookup expect(normalized.nodes.every((n: any) => n.type.startsWith('nodes-base.'))).toBe(true); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-fixed-collection-validation.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Workflow Fixed Collection Validation Tests * Tests that workflow validation catches fixedCollection structure errors at the workflow level */ import { describe, test, expect, beforeEach, vi } from 'vitest'; import { WorkflowValidator } from '../../../src/services/workflow-validator'; import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; import { NodeRepository } from '../../../src/database/node-repository'; describe('Workflow FixedCollection Validation', () => { let validator: WorkflowValidator; let mockNodeRepository: any; beforeEach(() => { // Create mock repository that returns basic node info for common nodes mockNodeRepository = { getNode: vi.fn().mockImplementation((type: string) => { const normalizedType = type.replace('n8n-nodes-base.', '').replace('nodes-base.', ''); switch (normalizedType) { case 'webhook': return { nodeType: 'nodes-base.webhook', displayName: 'Webhook', properties: [ { name: 'path', type: 'string', required: true }, { name: 'httpMethod', type: 'options' } ] }; case 'switch': return { nodeType: 'nodes-base.switch', displayName: 'Switch', properties: [ { name: 'rules', type: 'fixedCollection', required: true } ] }; case 'if': return { nodeType: 'nodes-base.if', displayName: 'If', properties: [ { name: 'conditions', type: 'filter', required: true } ] }; case 'filter': return { nodeType: 'nodes-base.filter', displayName: 'Filter', properties: [ { name: 'conditions', type: 'filter', required: true } ] }; default: return null; } }) }; validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); }); test('should catch invalid Switch node structure in workflow validation', async () => { const workflow = { name: 'Test Workflow with Invalid Switch', nodes: [ { id: 'webhook', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: { path: 'test-webhook' } }, { id: 'switch', name: 'Switch', type: 'n8n-nodes-base.switch', position: [200, 0] as [number, number], parameters: { // This is the problematic structure that causes "propertyValues[itemName] is not iterable" rules: { conditions: { values: [ { value1: '={{$json.status}}', operation: 'equals', value2: 'active' } ] } } } } ], connections: { Webhook: { main: [[{ node: 'Switch', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow, { validateNodes: true, profile: 'ai-friendly' }); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); const switchError = result.errors.find(e => e.nodeId === 'switch'); expect(switchError).toBeDefined(); expect(switchError!.message).toContain('propertyValues[itemName] is not iterable'); expect(switchError!.message).toContain('Invalid structure for nodes-base.switch node'); }); test('should catch invalid If node structure in workflow validation', async () => { const workflow = { name: 'Test Workflow with Invalid If', nodes: [ { id: 'webhook', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: { path: 'test-webhook' } }, { id: 'if', name: 'If', type: 'n8n-nodes-base.if', position: [200, 0] as [number, number], parameters: { // This is the problematic structure conditions: { values: [ { value1: '={{$json.age}}', operation: 'largerEqual', value2: 18 } ] } } } ], connections: { Webhook: { main: [[{ node: 'If', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow, { validateNodes: true, profile: 'ai-friendly' }); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); const ifError = result.errors.find(e => e.nodeId === 'if'); expect(ifError).toBeDefined(); expect(ifError!.message).toContain('Invalid structure for nodes-base.if node'); }); test('should accept valid Switch node structure in workflow validation', async () => { const workflow = { name: 'Test Workflow with Valid Switch', nodes: [ { id: 'webhook', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: { path: 'test-webhook' } }, { id: 'switch', name: 'Switch', type: 'n8n-nodes-base.switch', position: [200, 0] as [number, number], parameters: { // This is the correct structure rules: { values: [ { conditions: { value1: '={{$json.status}}', operation: 'equals', value2: 'active' }, outputKey: 'active' } ] } } } ], connections: { Webhook: { main: [[{ node: 'Switch', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow, { validateNodes: true, profile: 'ai-friendly' }); // Should not have fixedCollection structure errors const hasFixedCollectionError = result.errors.some(e => e.message.includes('propertyValues[itemName] is not iterable') ); expect(hasFixedCollectionError).toBe(false); }); test('should catch multiple fixedCollection errors in a single workflow', async () => { const workflow = { name: 'Test Workflow with Multiple Invalid Structures', nodes: [ { id: 'webhook', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: { path: 'test-webhook' } }, { id: 'switch', name: 'Switch', type: 'n8n-nodes-base.switch', position: [200, 0] as [number, number], parameters: { rules: { conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] } } } }, { id: 'if', name: 'If', type: 'n8n-nodes-base.if', position: [400, 0] as [number, number], parameters: { conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] } } }, { id: 'filter', name: 'Filter', type: 'n8n-nodes-base.filter', position: [600, 0] as [number, number], parameters: { conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] } } } ], connections: { Webhook: { main: [[{ node: 'Switch', type: 'main', index: 0 }]] }, Switch: { main: [ [{ node: 'If', type: 'main', index: 0 }], [{ node: 'Filter', type: 'main', index: 0 }] ] } } }; const result = await validator.validateWorkflow(workflow, { validateNodes: true, profile: 'ai-friendly' }); expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThanOrEqual(3); // At least one error for each problematic node // Check that each problematic node has an error const switchError = result.errors.find(e => e.nodeId === 'switch'); const ifError = result.errors.find(e => e.nodeId === 'if'); const filterError = result.errors.find(e => e.nodeId === 'filter'); expect(switchError).toBeDefined(); expect(ifError).toBeDefined(); expect(filterError).toBeDefined(); }); test('should provide helpful statistics about fixedCollection errors', async () => { const workflow = { name: 'Test Workflow Statistics', nodes: [ { id: 'webhook', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: { path: 'test' } }, { id: 'bad-switch', name: 'Bad Switch', type: 'n8n-nodes-base.switch', position: [200, 0] as [number, number], parameters: { rules: { conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] } } } }, { id: 'good-switch', name: 'Good Switch', type: 'n8n-nodes-base.switch', position: [400, 0] as [number, number], parameters: { rules: { values: [{ conditions: { value1: 'test', operation: 'equals', value2: 'test' }, outputKey: 'out' }] } } } ], connections: { Webhook: { main: [ [{ node: 'Bad Switch', type: 'main', index: 0 }], [{ node: 'Good Switch', type: 'main', index: 0 }] ] } } }; const result = await validator.validateWorkflow(workflow, { validateNodes: true, profile: 'ai-friendly' }); expect(result.statistics.totalNodes).toBe(3); expect(result.statistics.enabledNodes).toBe(3); expect(result.valid).toBe(false); // Should be invalid due to the bad switch // Should have at least one error for the bad switch const badSwitchError = result.errors.find(e => e.nodeId === 'bad-switch'); expect(badSwitchError).toBeDefined(); // Should not have errors for the good switch or webhook const goodSwitchError = result.errors.find(e => e.nodeId === 'good-switch'); const webhookError = result.errors.find(e => e.nodeId === 'webhook'); // These might have other validation errors, but not fixedCollection errors if (goodSwitchError) { expect(goodSwitchError.message).not.toContain('propertyValues[itemName] is not iterable'); } if (webhookError) { expect(webhookError.message).not.toContain('propertyValues[itemName] is not iterable'); } }); test('should work with different validation profiles', async () => { const workflow = { name: 'Test Profile Compatibility', nodes: [ { id: 'switch', name: 'Switch', type: 'n8n-nodes-base.switch', position: [0, 0] as [number, number], parameters: { rules: { conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] } } } } ], connections: {} }; const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> = ['strict', 'runtime', 'ai-friendly', 'minimal']; for (const profile of profiles) { const result = await validator.validateWorkflow(workflow, { validateNodes: true, profile }); // All profiles should catch this critical error const hasCriticalError = result.errors.some(e => e.message.includes('propertyValues[itemName] is not iterable') ); expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true); expect(result.valid, `Profile ${profile} should mark workflow as invalid`).toBe(false); } }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/fixed-collection-validation.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Fixed Collection Validation Tests * Tests for the fix of issue #90: "propertyValues[itemName] is not iterable" error * * This ensures AI agents cannot create invalid fixedCollection structures that break n8n UI */ import { describe, test, expect } from 'vitest'; import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; describe('FixedCollection Validation', () => { describe('Switch Node v2/v3 Validation', () => { test('should detect invalid nested conditions structure', () => { const invalidConfig = { rules: { conditions: { values: [ { value1: '={{$json.status}}', operation: 'equals', value2: 'active' } ] } } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', invalidConfig, [], 'operation', 'ai-friendly' ); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].type).toBe('invalid_value'); expect(result.errors[0].property).toBe('rules'); expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable'); expect(result.errors[0].fix).toContain('{ "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }'); }); test('should detect direct conditions in rules (another invalid pattern)', () => { const invalidConfig = { rules: { conditions: { value1: '={{$json.status}}', operation: 'equals', value2: 'active' } } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', invalidConfig, [], 'operation', 'ai-friendly' ); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].message).toContain('Invalid structure for nodes-base.switch node'); }); test('should provide auto-fix for invalid switch structure', () => { const invalidConfig = { rules: { conditions: { values: [ { value1: '={{$json.status}}', operation: 'equals', value2: 'active' } ] } } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', invalidConfig, [], 'operation', 'ai-friendly' ); expect(result.autofix).toBeDefined(); expect(result.autofix!.rules).toBeDefined(); expect(result.autofix!.rules.values).toBeInstanceOf(Array); expect(result.autofix!.rules.values).toHaveLength(1); expect(result.autofix!.rules.values[0]).toHaveProperty('conditions'); expect(result.autofix!.rules.values[0]).toHaveProperty('outputKey'); }); test('should accept valid switch structure', () => { const validConfig = { rules: { values: [ { conditions: { value1: '={{$json.status}}', operation: 'equals', value2: 'active' }, outputKey: 'active' } ] } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', validConfig, [], 'operation', 'ai-friendly' ); // Should not have the specific fixedCollection error const hasFixedCollectionError = result.errors.some(e => e.message.includes('propertyValues[itemName] is not iterable') ); expect(hasFixedCollectionError).toBe(false); }); test('should warn about missing outputKey in valid structure', () => { const configMissingOutputKey = { rules: { values: [ { conditions: { value1: '={{$json.status}}', operation: 'equals', value2: 'active' } // Missing outputKey } ] } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', configMissingOutputKey, [], 'operation', 'ai-friendly' ); const hasOutputKeyWarning = result.warnings.some(w => w.message.includes('missing "outputKey" property') ); expect(hasOutputKeyWarning).toBe(true); }); }); describe('If Node Validation', () => { test('should detect invalid nested values structure', () => { const invalidConfig = { conditions: { values: [ { value1: '={{$json.age}}', operation: 'largerEqual', value2: 18 } ] } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.if', invalidConfig, [], 'operation', 'ai-friendly' ); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].type).toBe('invalid_value'); expect(result.errors[0].property).toBe('conditions'); expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node'); expect(result.errors[0].fix).toBe('Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"'); }); test('should provide auto-fix for invalid if structure', () => { const invalidConfig = { conditions: { values: [ { value1: '={{$json.age}}', operation: 'largerEqual', value2: 18 } ] } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.if', invalidConfig, [], 'operation', 'ai-friendly' ); expect(result.autofix).toBeDefined(); expect(result.autofix!.conditions).toEqual(invalidConfig.conditions.values); }); test('should accept valid if structure', () => { const validConfig = { conditions: { value1: '={{$json.age}}', operation: 'largerEqual', value2: 18 } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.if', validConfig, [], 'operation', 'ai-friendly' ); // Should not have the specific structure error const hasStructureError = result.errors.some(e => e.message.includes('should be a filter object/array directly') ); expect(hasStructureError).toBe(false); }); }); describe('Filter Node Validation', () => { test('should detect invalid nested values structure', () => { const invalidConfig = { conditions: { values: [ { value1: '={{$json.score}}', operation: 'larger', value2: 80 } ] } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.filter', invalidConfig, [], 'operation', 'ai-friendly' ); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].type).toBe('invalid_value'); expect(result.errors[0].property).toBe('conditions'); expect(result.errors[0].message).toContain('Invalid structure for nodes-base.filter node'); }); test('should accept valid filter structure', () => { const validConfig = { conditions: { value1: '={{$json.score}}', operation: 'larger', value2: 80 } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.filter', validConfig, [], 'operation', 'ai-friendly' ); // Should not have the specific structure error const hasStructureError = result.errors.some(e => e.message.includes('should be a filter object/array directly') ); expect(hasStructureError).toBe(false); }); }); describe('Edge Cases', () => { test('should not validate non-problematic nodes', () => { const config = { someProperty: { conditions: { values: ['should', 'not', 'trigger', 'validation'] } } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.httpRequest', config, [], 'operation', 'ai-friendly' ); // Should not have fixedCollection errors for non-problematic nodes const hasFixedCollectionError = result.errors.some(e => e.message.includes('propertyValues[itemName] is not iterable') ); expect(hasFixedCollectionError).toBe(false); }); test('should handle empty config gracefully', () => { const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', {}, [], 'operation', 'ai-friendly' ); // Should not crash or produce false positives expect(result).toBeDefined(); expect(result.errors).toBeInstanceOf(Array); }); test('should handle non-object property values', () => { const config = { rules: 'not an object' }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', config, [], 'operation', 'ai-friendly' ); // Should not crash on non-object values expect(result).toBeDefined(); expect(result.errors).toBeInstanceOf(Array); }); }); describe('Real-world AI Agent Patterns', () => { test('should catch common ChatGPT/Claude switch patterns', () => { // This is a pattern commonly generated by AI agents const aiGeneratedConfig = { rules: { conditions: { values: [ { "value1": "={{$json.status}}", "operation": "equals", "value2": "active" }, { "value1": "={{$json.priority}}", "operation": "equals", "value2": "high" } ] } } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', aiGeneratedConfig, [], 'operation', 'ai-friendly' ); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable'); // Check auto-fix generates correct structure expect(result.autofix!.rules.values).toHaveLength(2); result.autofix!.rules.values.forEach((rule: any) => { expect(rule).toHaveProperty('conditions'); expect(rule).toHaveProperty('outputKey'); }); }); test('should catch common AI if/filter patterns', () => { const aiGeneratedIfConfig = { conditions: { values: { "value1": "={{$json.age}}", "operation": "largerEqual", "value2": 21 } } }; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.if', aiGeneratedIfConfig, [], 'operation', 'ai-friendly' ); expect(result.valid).toBe(false); expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node'); }); }); describe('Version Compatibility', () => { test('should work across different validation profiles', () => { const invalidConfig = { rules: { conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] } } }; const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> = ['strict', 'runtime', 'ai-friendly', 'minimal']; profiles.forEach(profile => { const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.switch', invalidConfig, [], 'operation', profile ); // All profiles should catch this critical error const hasCriticalError = result.errors.some(e => e.message.includes('propertyValues[itemName] is not iterable') ); expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true); }); }); }); }); ``` -------------------------------------------------------------------------------- /src/telemetry/config-manager.ts: -------------------------------------------------------------------------------- ```typescript /** * Telemetry Configuration Manager * Handles telemetry settings, opt-in/opt-out, and first-run detection */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, resolve, dirname } from 'path'; import { homedir } from 'os'; import { createHash } from 'crypto'; import { hostname, platform, arch } from 'os'; export interface TelemetryConfig { enabled: boolean; userId: string; firstRun?: string; lastModified?: string; version?: string; } export class TelemetryConfigManager { private static instance: TelemetryConfigManager; private readonly configDir: string; private readonly configPath: string; private config: TelemetryConfig | null = null; private constructor() { this.configDir = join(homedir(), '.n8n-mcp'); this.configPath = join(this.configDir, 'telemetry.json'); } static getInstance(): TelemetryConfigManager { if (!TelemetryConfigManager.instance) { TelemetryConfigManager.instance = new TelemetryConfigManager(); } return TelemetryConfigManager.instance; } /** * Generate a deterministic anonymous user ID based on machine characteristics * Uses Docker/cloud-specific method for containerized environments */ private generateUserId(): string { // Use boot_id for all Docker/cloud environments (stable across container updates) if (process.env.IS_DOCKER === 'true' || this.isCloudEnvironment()) { return this.generateDockerStableId(); } // Local installations use file-based method with hostname const machineId = `${hostname()}-${platform()}-${arch()}-${homedir()}`; return createHash('sha256').update(machineId).digest('hex').substring(0, 16); } /** * Generate stable user ID for Docker/cloud environments * Priority: boot_id → combined signals → generic fallback */ private generateDockerStableId(): string { // Priority 1: Try boot_id (stable across container recreations) const bootId = this.readBootId(); if (bootId) { const fingerprint = `${bootId}-${platform()}-${arch()}`; return createHash('sha256').update(fingerprint).digest('hex').substring(0, 16); } // Priority 2: Try combined host signals const combinedFingerprint = this.generateCombinedFingerprint(); if (combinedFingerprint) { return combinedFingerprint; } // Priority 3: Generic Docker ID (allows aggregate statistics) const genericId = `docker-${platform()}-${arch()}`; return createHash('sha256').update(genericId).digest('hex').substring(0, 16); } /** * Read host boot_id from /proc (available in Linux containers) * Returns null if not available or invalid format */ private readBootId(): string | null { try { const bootIdPath = '/proc/sys/kernel/random/boot_id'; if (!existsSync(bootIdPath)) { return null; } const bootId = readFileSync(bootIdPath, 'utf-8').trim(); // Validate UUID format (8-4-4-4-12 hex digits) const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!uuidRegex.test(bootId)) { return null; } return bootId; } catch (error) { // File not readable or other error return null; } } /** * Generate fingerprint from combined host signals * Fallback for environments where boot_id is not available */ private generateCombinedFingerprint(): string | null { try { const signals: string[] = []; // CPU cores (stable) if (existsSync('/proc/cpuinfo')) { const cpuinfo = readFileSync('/proc/cpuinfo', 'utf-8'); const cores = (cpuinfo.match(/processor\s*:/g) || []).length; if (cores > 0) { signals.push(`cores:${cores}`); } } // Memory (stable) if (existsSync('/proc/meminfo')) { const meminfo = readFileSync('/proc/meminfo', 'utf-8'); const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/); if (totalMatch) { signals.push(`mem:${totalMatch[1]}`); } } // Kernel version (stable) if (existsSync('/proc/version')) { const version = readFileSync('/proc/version', 'utf-8'); const kernelMatch = version.match(/Linux version ([\d.]+)/); if (kernelMatch) { signals.push(`kernel:${kernelMatch[1]}`); } } // Platform and arch signals.push(platform(), arch()); // Need at least 3 signals for reasonable uniqueness if (signals.length < 3) { return null; } const fingerprint = signals.join('-'); return createHash('sha256').update(fingerprint).digest('hex').substring(0, 16); } catch (error) { return null; } } /** * Check if running in a cloud environment */ private isCloudEnvironment(): boolean { return !!( process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.FLY_APP_NAME || process.env.HEROKU_APP_NAME || process.env.AWS_EXECUTION_ENV || process.env.KUBERNETES_SERVICE_HOST || process.env.GOOGLE_CLOUD_PROJECT || process.env.AZURE_FUNCTIONS_ENVIRONMENT ); } /** * Load configuration from disk or create default */ loadConfig(): TelemetryConfig { if (this.config) { return this.config; } if (!existsSync(this.configPath)) { // First run - create default config const version = this.getPackageVersion(); // Check if telemetry is disabled via environment variable const envDisabled = this.isDisabledByEnvironment(); this.config = { enabled: !envDisabled, // Respect env var on first run userId: this.generateUserId(), firstRun: new Date().toISOString(), version }; this.saveConfig(); // Only show notice if not disabled via environment if (!envDisabled) { this.showFirstRunNotice(); } return this.config; } try { const rawConfig = readFileSync(this.configPath, 'utf-8'); this.config = JSON.parse(rawConfig); // Ensure userId exists (for upgrades from older versions) if (!this.config!.userId) { this.config!.userId = this.generateUserId(); this.saveConfig(); } return this.config!; } catch (error) { console.error('Failed to load telemetry config, using defaults:', error); this.config = { enabled: false, userId: this.generateUserId() }; return this.config; } } /** * Save configuration to disk */ private saveConfig(): void { if (!this.config) return; try { if (!existsSync(this.configDir)) { mkdirSync(this.configDir, { recursive: true }); } this.config.lastModified = new Date().toISOString(); writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); } catch (error) { console.error('Failed to save telemetry config:', error); } } /** * Check if telemetry is enabled * Priority: Environment variable > Config file > Default (true) */ isEnabled(): boolean { // Check environment variables first (for Docker users) if (this.isDisabledByEnvironment()) { return false; } const config = this.loadConfig(); return config.enabled; } /** * Check if telemetry is disabled via environment variable */ private isDisabledByEnvironment(): boolean { const envVars = [ 'N8N_MCP_TELEMETRY_DISABLED', 'TELEMETRY_DISABLED', 'DISABLE_TELEMETRY' ]; for (const varName of envVars) { const value = process.env[varName]; if (value !== undefined) { const normalized = value.toLowerCase().trim(); // Warn about invalid values if (!['true', 'false', '1', '0', ''].includes(normalized)) { console.warn( `⚠️ Invalid telemetry environment variable value: ${varName}="${value}"\n` + ` Use "true" to disable or "false" to enable telemetry.` ); } // Accept common truthy values if (normalized === 'true' || normalized === '1') { return true; } } } return false; } /** * Get the anonymous user ID */ getUserId(): string { const config = this.loadConfig(); return config.userId; } /** * Check if this is the first run */ isFirstRun(): boolean { return !existsSync(this.configPath); } /** * Enable telemetry */ enable(): void { const config = this.loadConfig(); config.enabled = true; this.config = config; this.saveConfig(); console.log('✓ Anonymous telemetry enabled'); } /** * Disable telemetry */ disable(): void { const config = this.loadConfig(); config.enabled = false; this.config = config; this.saveConfig(); console.log('✓ Anonymous telemetry disabled'); } /** * Get current status */ getStatus(): string { const config = this.loadConfig(); // Check if disabled by environment const envDisabled = this.isDisabledByEnvironment(); let status = config.enabled ? 'ENABLED' : 'DISABLED'; if (envDisabled) { status = 'DISABLED (via environment variable)'; } return ` Telemetry Status: ${status} Anonymous ID: ${config.userId} First Run: ${config.firstRun || 'Unknown'} Config Path: ${this.configPath} To opt-out: npx n8n-mcp telemetry disable To opt-in: npx n8n-mcp telemetry enable For Docker: Set N8N_MCP_TELEMETRY_DISABLED=true `; } /** * Show first-run notice to user */ private showFirstRunNotice(): void { console.log(` ╔════════════════════════════════════════════════════════════╗ ║ Anonymous Usage Statistics ║ ╠════════════════════════════════════════════════════════════╣ ║ ║ ║ n8n-mcp collects anonymous usage data to improve the ║ ║ tool and understand how it's being used. ║ ║ ║ ║ We track: ║ ║ • Which MCP tools are used (no parameters) ║ ║ • Workflow structures (sanitized, no sensitive data) ║ ║ • Error patterns (hashed, no details) ║ ║ • Performance metrics (timing, success rates) ║ ║ ║ ║ We NEVER collect: ║ ║ • URLs, API keys, or credentials ║ ║ • Workflow content or actual data ║ ║ • Personal or identifiable information ║ ║ • n8n instance details or locations ║ ║ ║ ║ Your anonymous ID: ${this.config?.userId || 'generating...'} ║ ║ ║ ║ This helps me understand usage patterns and improve ║ ║ n8n-mcp for everyone. Thank you for your support! ║ ║ ║ ║ To opt-out at any time: ║ ║ npx n8n-mcp telemetry disable ║ ║ ║ ║ Data deletion requests: ║ ║ Email [email protected] with your anonymous ID ║ ║ ║ ║ Learn more: ║ ║ https://github.com/czlonkowski/n8n-mcp/blob/main/PRIVACY.md ║ ║ ║ ╚════════════════════════════════════════════════════════════╝ `); } /** * Get package version safely */ private getPackageVersion(): string { try { // Try multiple approaches to find package.json const possiblePaths = [ resolve(__dirname, '..', '..', 'package.json'), resolve(process.cwd(), 'package.json'), resolve(__dirname, '..', '..', '..', 'package.json') ]; for (const packagePath of possiblePaths) { if (existsSync(packagePath)) { const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8')); if (packageJson.version) { return packageJson.version; } } } // Fallback: try require (works in some environments) try { const packageJson = require('../../package.json'); return packageJson.version || 'unknown'; } catch { // Ignore require error } return 'unknown'; } catch (error) { return 'unknown'; } } } ``` -------------------------------------------------------------------------------- /scripts/prepare-release.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node /** * Pre-release preparation script * Validates and prepares everything needed for a successful release */ const fs = require('fs'); const path = require('path'); const { execSync, spawnSync } = require('child_process'); const readline = require('readline'); // Color codes const colors = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }; function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`); } function success(message) { log(`✅ ${message}`, 'green'); } function warning(message) { log(`⚠️ ${message}`, 'yellow'); } function error(message) { log(`❌ ${message}`, 'red'); } function info(message) { log(`ℹ️ ${message}`, 'blue'); } function header(title) { log(`\n${'='.repeat(60)}`, 'cyan'); log(`🚀 ${title}`, 'cyan'); log(`${'='.repeat(60)}`, 'cyan'); } class ReleasePreparation { constructor() { this.rootDir = path.resolve(__dirname, '..'); this.rl = readline.createInterface({ input: process.stdin, output: process.stdout }); } async askQuestion(question) { return new Promise((resolve) => { this.rl.question(question, resolve); }); } /** * Get current version and ask for new version */ async getVersionInfo() { const packageJson = require(path.join(this.rootDir, 'package.json')); const currentVersion = packageJson.version; log(`\nCurrent version: ${currentVersion}`, 'blue'); const newVersion = await this.askQuestion('\nEnter new version (e.g., 2.10.0): '); if (!newVersion || !this.isValidSemver(newVersion)) { error('Invalid semantic version format'); throw new Error('Invalid version'); } if (this.compareVersions(newVersion, currentVersion) <= 0) { error('New version must be greater than current version'); throw new Error('Version not incremented'); } return { currentVersion, newVersion }; } /** * Validate semantic version format (strict semver compliance) */ isValidSemver(version) { // Strict semantic versioning regex 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-]+)*))?$/; return semverRegex.test(version); } /** * Compare two semantic versions */ compareVersions(v1, v2) { const parseVersion = (v) => v.split('-')[0].split('.').map(Number); const [v1Parts, v2Parts] = [parseVersion(v1), parseVersion(v2)]; for (let i = 0; i < 3; i++) { if (v1Parts[i] > v2Parts[i]) return 1; if (v1Parts[i] < v2Parts[i]) return -1; } return 0; } /** * Update version in package files */ updateVersions(newVersion) { log('\n📝 Updating version in package files...', 'blue'); // Update package.json const packageJsonPath = path.join(this.rootDir, 'package.json'); const packageJson = require(packageJsonPath); packageJson.version = newVersion; fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); success('Updated package.json'); // Sync to runtime package try { execSync('npm run sync:runtime-version', { cwd: this.rootDir, stdio: 'pipe' }); success('Synced package.runtime.json'); } catch (err) { warning('Could not sync runtime version automatically'); // Manual sync const runtimeJsonPath = path.join(this.rootDir, 'package.runtime.json'); if (fs.existsSync(runtimeJsonPath)) { const runtimeJson = require(runtimeJsonPath); runtimeJson.version = newVersion; fs.writeFileSync(runtimeJsonPath, JSON.stringify(runtimeJson, null, 2) + '\n'); success('Manually synced package.runtime.json'); } } } /** * Update changelog */ async updateChangelog(newVersion) { const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md'); if (!fs.existsSync(changelogPath)) { warning('Changelog file not found, skipping update'); return; } log('\n📋 Updating changelog...', 'blue'); const content = fs.readFileSync(changelogPath, 'utf8'); const today = new Date().toISOString().split('T')[0]; // Check if version already exists in changelog const versionRegex = new RegExp(`^## \\[${newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm'); if (versionRegex.test(content)) { info(`Version ${newVersion} already exists in changelog`); return; } // Find the Unreleased section const unreleasedMatch = content.match(/^## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/m); if (unreleasedMatch) { const unreleasedContent = unreleasedMatch[1].trim(); if (unreleasedContent) { log('\nFound content in Unreleased section:', 'blue'); log(unreleasedContent.substring(0, 200) + '...', 'yellow'); const moveContent = await this.askQuestion('\nMove this content to the new version? (y/n): '); if (moveContent.toLowerCase() === 'y') { // Move unreleased content to new version const newVersionSection = `## [${newVersion}] - ${today}\n\n${unreleasedContent}\n\n`; const updatedContent = content.replace( /^## \[Unreleased\]\s*\n[\s\S]*?(?=\n## \[)/m, `## [Unreleased]\n\n${newVersionSection}## [` ); fs.writeFileSync(changelogPath, updatedContent); success(`Moved unreleased content to version ${newVersion}`); } else { // Just add empty version section const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`; const updatedContent = content.replace( /^## \[Unreleased\]\s*\n/m, `## [Unreleased]\n\n${newVersionSection}` ); fs.writeFileSync(changelogPath, updatedContent); warning(`Added empty version section for ${newVersion} - please fill in the changes`); } } else { // Add empty version section const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`; const updatedContent = content.replace( /^## \[Unreleased\]\s*\n/m, `## [Unreleased]\n\n${newVersionSection}` ); fs.writeFileSync(changelogPath, updatedContent); warning(`Added empty version section for ${newVersion} - please fill in the changes`); } } else { warning('Could not find Unreleased section in changelog'); } info('Please review and edit the changelog before committing'); } /** * Run tests and build */ async runChecks() { log('\n🧪 Running pre-release checks...', 'blue'); try { // Run tests log('Running tests...', 'blue'); execSync('npm test', { cwd: this.rootDir, stdio: 'inherit' }); success('All tests passed'); // Run build log('Building project...', 'blue'); execSync('npm run build', { cwd: this.rootDir, stdio: 'inherit' }); success('Build completed'); // Rebuild database log('Rebuilding database...', 'blue'); execSync('npm run rebuild', { cwd: this.rootDir, stdio: 'inherit' }); success('Database rebuilt'); // Run type checking log('Type checking...', 'blue'); execSync('npm run typecheck', { cwd: this.rootDir, stdio: 'inherit' }); success('Type checking passed'); } catch (err) { error('Pre-release checks failed'); throw err; } } /** * Create git commit */ async createCommit(newVersion) { log('\n📝 Creating git commit...', 'blue'); try { // Check git status const status = execSync('git status --porcelain', { cwd: this.rootDir, encoding: 'utf8' }); if (!status.trim()) { info('No changes to commit'); return; } // Show what will be committed log('\nFiles to be committed:', 'blue'); execSync('git diff --name-only', { cwd: this.rootDir, stdio: 'inherit' }); const commit = await this.askQuestion('\nCreate commit for release? (y/n): '); if (commit.toLowerCase() === 'y') { // Add files execSync('git add package.json package.runtime.json docs/CHANGELOG.md', { cwd: this.rootDir, stdio: 'pipe' }); // Create commit const commitMessage = `chore: release v${newVersion} 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>`; const result = spawnSync('git', ['commit', '-m', commitMessage], { cwd: this.rootDir, stdio: 'pipe', encoding: 'utf8' }); if (result.error || result.status !== 0) { throw new Error(`Git commit failed: ${result.stderr || result.error?.message}`); } success(`Created commit for v${newVersion}`); const push = await this.askQuestion('\nPush to trigger release workflow? (y/n): '); if (push.toLowerCase() === 'y') { // Add confirmation for destructive operation warning('\n⚠️ DESTRUCTIVE OPERATION WARNING ⚠️'); warning('This will trigger a PUBLIC RELEASE that cannot be undone!'); warning('The following will happen automatically:'); warning('• Create GitHub release with tag'); warning('• Publish package to NPM registry'); warning('• Build and push Docker images'); warning('• Update documentation'); const confirmation = await this.askQuestion('\nType "RELEASE" (all caps) to confirm: '); if (confirmation === 'RELEASE') { execSync('git push', { cwd: this.rootDir, stdio: 'inherit' }); success('Pushed to remote repository'); log('\n🎉 Release workflow will be triggered automatically!', 'green'); log('Monitor progress at: https://github.com/czlonkowski/n8n-mcp/actions', 'blue'); } else { warning('Release cancelled. Commit created but not pushed.'); info('You can push manually later to trigger the release.'); } } else { info('Commit created but not pushed. Push manually to trigger release.'); } } } catch (err) { error(`Git operations failed: ${err.message}`); throw err; } } /** * Display final instructions */ displayInstructions(newVersion) { header('Release Preparation Complete'); log('📋 What happens next:', 'blue'); log(`1. The GitHub Actions workflow will detect the version change to v${newVersion}`, 'green'); log('2. It will automatically:', 'green'); log(' • Create a GitHub release with changelog content', 'green'); log(' • Publish the npm package', 'green'); log(' • Build and push Docker images', 'green'); log(' • Update documentation badges', 'green'); log('\n🔍 Monitor the release at:', 'blue'); log(' • GitHub Actions: https://github.com/czlonkowski/n8n-mcp/actions', 'blue'); log(' • NPM Package: https://www.npmjs.com/package/n8n-mcp', 'blue'); log(' • Docker Images: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp', 'blue'); log('\n✅ Release preparation completed successfully!', 'green'); } /** * Main execution flow */ async run() { try { header('n8n-MCP Release Preparation'); // Get version information const { currentVersion, newVersion } = await this.getVersionInfo(); log(`\n🔄 Preparing release: ${currentVersion} → ${newVersion}`, 'magenta'); // Update versions this.updateVersions(newVersion); // Update changelog await this.updateChangelog(newVersion); // Run pre-release checks await this.runChecks(); // Create git commit await this.createCommit(newVersion); // Display final instructions this.displayInstructions(newVersion); } catch (err) { error(`Release preparation failed: ${err.message}`); process.exit(1); } finally { this.rl.close(); } } } // Run the script if (require.main === module) { const preparation = new ReleasePreparation(); preparation.run().catch(err => { console.error('Release preparation failed:', err); process.exit(1); }); } module.exports = ReleasePreparation; ``` -------------------------------------------------------------------------------- /tests/unit/mcp/search-nodes-examples.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { N8NDocumentationMCPServer } from '../../../src/mcp/server'; import { createDatabaseAdapter } from '../../../src/database/database-adapter'; import path from 'path'; import fs from 'fs'; /** * Unit tests for search_nodes with includeExamples parameter * Testing P0-R3 feature: Template-based configuration examples */ describe('search_nodes with includeExamples', () => { let server: N8NDocumentationMCPServer; let dbPath: string; beforeEach(async () => { // Use in-memory database for testing process.env.NODE_DB_PATH = ':memory:'; server = new N8NDocumentationMCPServer(); await (server as any).initialized; // Populate in-memory database with test nodes // NOTE: Database stores nodes in SHORT form (nodes-base.xxx, not n8n-nodes-base.xxx) const testNodes = [ { node_type: 'nodes-base.webhook', package_name: 'n8n-nodes-base', display_name: 'Webhook', description: 'Starts workflow on webhook call', category: 'Core Nodes', is_ai_tool: 0, is_trigger: 1, is_webhook: 1, is_versioned: 1, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]) }, { node_type: 'nodes-base.httpRequest', package_name: 'n8n-nodes-base', display_name: 'HTTP Request', description: 'Makes an HTTP request', category: 'Core Nodes', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 1, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]) } ]; // Insert test nodes into the in-memory database const db = (server as any).db; if (db) { const insertStmt = db.prepare(` INSERT INTO nodes ( node_type, package_name, display_name, description, category, is_ai_tool, is_trigger, is_webhook, is_versioned, version, properties_schema, operations ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const node of testNodes) { insertStmt.run( node.node_type, node.package_name, node.display_name, node.description, node.category, node.is_ai_tool, node.is_trigger, node.is_webhook, node.is_versioned, node.version, node.properties_schema, node.operations ); } // Note: FTS table is not created in test environment // searchNodes will fall back to LIKE search when FTS doesn't exist } }); afterEach(() => { delete process.env.NODE_DB_PATH; }); describe('includeExamples parameter', () => { it('should not include examples when includeExamples is false', async () => { const result = await (server as any).searchNodes('webhook', 5, { includeExamples: false }); expect(result.results).toBeDefined(); if (result.results.length > 0) { result.results.forEach((node: any) => { expect(node.examples).toBeUndefined(); }); } }); it('should not include examples when includeExamples is undefined', async () => { const result = await (server as any).searchNodes('webhook', 5, {}); expect(result.results).toBeDefined(); if (result.results.length > 0) { result.results.forEach((node: any) => { expect(node.examples).toBeUndefined(); }); } }); it('should include examples when includeExamples is true', async () => { const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true }); expect(result.results).toBeDefined(); // Note: In-memory test database may not have template configs // This test validates the parameter is processed correctly }); it('should handle nodes without examples gracefully', async () => { const result = await (server as any).searchNodes('nonexistent', 5, { includeExamples: true }); expect(result.results).toBeDefined(); expect(result.results).toHaveLength(0); }); it('should limit examples to top 2 per node', async () => { // This test would need a database with actual template_node_configs data // In a real scenario, we'd verify that only 2 examples are returned const result = await (server as any).searchNodes('http', 5, { includeExamples: true }); expect(result.results).toBeDefined(); if (result.results.length > 0) { result.results.forEach((node: any) => { if (node.examples) { expect(node.examples.length).toBeLessThanOrEqual(2); } }); } }); }); describe('example data structure', () => { it('should return examples with correct structure when present', async () => { // Mock database to return example data const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { return { all: vi.fn(() => [ { parameters_json: JSON.stringify({ httpMethod: 'POST', path: 'webhook-test' }), template_name: 'Test Template', template_views: 1000 }, { parameters_json: JSON.stringify({ httpMethod: 'GET', path: 'webhook-get' }), template_name: 'Another Template', template_views: 500 } ]) }; } return originalPrepare(query); }); const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true }); if (result.results.length > 0 && result.results[0].examples) { const example = result.results[0].examples[0]; expect(example).toHaveProperty('configuration'); expect(example).toHaveProperty('template'); expect(example).toHaveProperty('views'); expect(typeof example.configuration).toBe('object'); expect(typeof example.template).toBe('string'); expect(typeof example.views).toBe('number'); } } }); }); describe('backward compatibility', () => { it('should maintain backward compatibility when includeExamples not specified', async () => { const resultWithoutParam = await (server as any).searchNodes('http', 5); const resultWithFalse = await (server as any).searchNodes('http', 5, { includeExamples: false }); expect(resultWithoutParam.results).toBeDefined(); expect(resultWithFalse.results).toBeDefined(); // Both should have same structure (no examples) if (resultWithoutParam.results.length > 0) { expect(resultWithoutParam.results[0].examples).toBeUndefined(); } if (resultWithFalse.results.length > 0) { expect(resultWithFalse.results[0].examples).toBeUndefined(); } }); }); describe('performance considerations', () => { it('should not significantly impact performance when includeExamples is false', async () => { const startWithout = Date.now(); await (server as any).searchNodes('http', 20, { includeExamples: false }); const durationWithout = Date.now() - startWithout; const startWith = Date.now(); await (server as any).searchNodes('http', 20, { includeExamples: true }); const durationWith = Date.now() - startWith; // Both should complete quickly (under 100ms) expect(durationWithout).toBeLessThan(100); expect(durationWith).toBeLessThan(200); }); }); describe('error handling', () => { it('should continue to work even if example fetch fails', async () => { // Mock database to throw error on example fetch const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { throw new Error('Database error'); } return originalPrepare(query); }); // Should not throw, should return results without examples const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true }); expect(result.results).toBeDefined(); // Examples should be undefined due to error if (result.results.length > 0) { expect(result.results[0].examples).toBeUndefined(); } } }); it('should handle malformed parameters_json gracefully', async () => { const mockDb = (server as any).db; if (mockDb) { const originalPrepare = mockDb.prepare.bind(mockDb); mockDb.prepare = vi.fn((query: string) => { if (query.includes('template_node_configs')) { return { all: vi.fn(() => [ { parameters_json: 'invalid json', template_name: 'Test Template', template_views: 1000 } ]) }; } return originalPrepare(query); }); // Should not throw const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true }); expect(result).toBeDefined(); } }); }); }); describe('searchNodesLIKE with includeExamples', () => { let server: N8NDocumentationMCPServer; beforeEach(async () => { process.env.NODE_DB_PATH = ':memory:'; server = new N8NDocumentationMCPServer(); await (server as any).initialized; // Populate in-memory database with test nodes const testNodes = [ { node_type: 'nodes-base.webhook', package_name: 'n8n-nodes-base', display_name: 'Webhook', description: 'Starts workflow on webhook call', category: 'Core Nodes', is_ai_tool: 0, is_trigger: 1, is_webhook: 1, is_versioned: 1, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]) } ]; const db = (server as any).db; if (db) { const insertStmt = db.prepare(` INSERT INTO nodes ( node_type, package_name, display_name, description, category, is_ai_tool, is_trigger, is_webhook, is_versioned, version, properties_schema, operations ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const node of testNodes) { insertStmt.run( node.node_type, node.package_name, node.display_name, node.description, node.category, node.is_ai_tool, node.is_trigger, node.is_webhook, node.is_versioned, node.version, node.properties_schema, node.operations ); } } }); afterEach(() => { delete process.env.NODE_DB_PATH; }); it('should support includeExamples in LIKE search', async () => { const result = await (server as any).searchNodesLIKE('webhook', 5, { includeExamples: true }); expect(result).toBeDefined(); expect(result.results).toBeDefined(); expect(Array.isArray(result.results)).toBe(true); }); it('should not include examples when includeExamples is false', async () => { const result = await (server as any).searchNodesLIKE('webhook', 5, { includeExamples: false }); expect(result).toBeDefined(); expect(result.results).toBeDefined(); if (result.results.length > 0) { result.results.forEach((node: any) => { expect(node.examples).toBeUndefined(); }); } }); }); describe('searchNodesFTS with includeExamples', () => { let server: N8NDocumentationMCPServer; beforeEach(async () => { process.env.NODE_DB_PATH = ':memory:'; server = new N8NDocumentationMCPServer(); await (server as any).initialized; }); afterEach(() => { delete process.env.NODE_DB_PATH; }); it('should support includeExamples in FTS search', async () => { const result = await (server as any).searchNodesFTS('webhook', 5, 'OR', { includeExamples: true }); expect(result.results).toBeDefined(); expect(Array.isArray(result.results)).toBe(true); }); it('should pass options to example fetching logic', async () => { const result = await (server as any).searchNodesFTS('http', 5, 'AND', { includeExamples: true }); expect(result).toBeDefined(); expect(result.results).toBeDefined(); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/expression-format-validator.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import { ExpressionFormatValidator } from '../../../src/services/expression-format-validator'; describe('ExpressionFormatValidator', () => { describe('validateAndFix', () => { const context = { nodeType: 'n8n-nodes-base.httpRequest', nodeName: 'HTTP Request', nodeId: 'test-id-1' }; describe('Simple string expressions', () => { it('should detect missing = prefix for expression', () => { const value = '{{ $env.API_KEY }}'; const issue = ExpressionFormatValidator.validateAndFix(value, 'apiKey', context); expect(issue).toBeTruthy(); expect(issue?.issueType).toBe('missing-prefix'); expect(issue?.correctedValue).toBe('={{ $env.API_KEY }}'); expect(issue?.severity).toBe('error'); }); it('should accept expression with = prefix', () => { const value = '={{ $env.API_KEY }}'; const issue = ExpressionFormatValidator.validateAndFix(value, 'apiKey', context); expect(issue).toBeNull(); }); it('should detect mixed content without prefix', () => { const value = 'Bearer {{ $env.TOKEN }}'; const issue = ExpressionFormatValidator.validateAndFix(value, 'authorization', context); expect(issue).toBeTruthy(); expect(issue?.issueType).toBe('missing-prefix'); expect(issue?.correctedValue).toBe('=Bearer {{ $env.TOKEN }}'); }); it('should accept mixed content with prefix', () => { const value = '=Bearer {{ $env.TOKEN }}'; const issue = ExpressionFormatValidator.validateAndFix(value, 'authorization', context); expect(issue).toBeNull(); }); it('should ignore plain strings without expressions', () => { const value = 'https://api.example.com'; const issue = ExpressionFormatValidator.validateAndFix(value, 'url', context); expect(issue).toBeNull(); }); }); describe('Resource Locator fields', () => { const githubContext = { nodeType: 'n8n-nodes-base.github', nodeName: 'GitHub', nodeId: 'github-1' }; it('should detect expression in owner field needing resource locator', () => { const value = '{{ $vars.GITHUB_OWNER }}'; const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); expect(issue).toBeTruthy(); expect(issue?.issueType).toBe('needs-resource-locator'); expect(issue?.correctedValue).toEqual({ __rl: true, value: '={{ $vars.GITHUB_OWNER }}', mode: 'expression' }); expect(issue?.severity).toBe('error'); }); it('should accept resource locator with expression', () => { const value = { __rl: true, value: '={{ $vars.GITHUB_OWNER }}', mode: 'expression' }; const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); expect(issue).toBeNull(); }); it('should detect missing prefix in resource locator value', () => { const value = { __rl: true, value: '{{ $vars.GITHUB_OWNER }}', mode: 'expression' }; const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); expect(issue).toBeTruthy(); expect(issue?.issueType).toBe('missing-prefix'); expect(issue?.correctedValue.value).toBe('={{ $vars.GITHUB_OWNER }}'); }); it('should warn if expression has prefix but should use RL format', () => { const value = '={{ $vars.GITHUB_OWNER }}'; const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); expect(issue).toBeTruthy(); expect(issue?.issueType).toBe('needs-resource-locator'); expect(issue?.severity).toBe('warning'); }); }); describe('Multiple expressions', () => { it('should detect multiple expressions without prefix', () => { const value = '{{ $json.first }} - {{ $json.last }}'; const issue = ExpressionFormatValidator.validateAndFix(value, 'fullName', context); expect(issue).toBeTruthy(); expect(issue?.issueType).toBe('missing-prefix'); expect(issue?.correctedValue).toBe('={{ $json.first }} - {{ $json.last }}'); }); it('should accept multiple expressions with prefix', () => { const value = '={{ $json.first }} - {{ $json.last }}'; const issue = ExpressionFormatValidator.validateAndFix(value, 'fullName', context); expect(issue).toBeNull(); }); }); describe('Edge cases', () => { it('should handle null values', () => { const issue = ExpressionFormatValidator.validateAndFix(null, 'field', context); expect(issue).toBeNull(); }); it('should handle undefined values', () => { const issue = ExpressionFormatValidator.validateAndFix(undefined, 'field', context); expect(issue).toBeNull(); }); it('should handle empty strings', () => { const issue = ExpressionFormatValidator.validateAndFix('', 'field', context); expect(issue).toBeNull(); }); it('should handle numbers', () => { const issue = ExpressionFormatValidator.validateAndFix(42, 'field', context); expect(issue).toBeNull(); }); it('should handle booleans', () => { const issue = ExpressionFormatValidator.validateAndFix(true, 'field', context); expect(issue).toBeNull(); }); it('should handle arrays', () => { const issue = ExpressionFormatValidator.validateAndFix(['item1', 'item2'], 'field', context); expect(issue).toBeNull(); }); }); }); describe('validateNodeParameters', () => { const context = { nodeType: 'n8n-nodes-base.emailSend', nodeName: 'Send Email', nodeId: 'email-1' }; it('should validate all parameters recursively', () => { const parameters = { fromEmail: '{{ $env.SENDER_EMAIL }}', toEmail: '[email protected]', subject: 'Test {{ $json.type }}', body: { html: '<p>Hello {{ $json.name }}</p>', text: 'Hello {{ $json.name }}' }, options: { replyTo: '={{ $env.REPLY_EMAIL }}' } }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); expect(issues).toHaveLength(4); expect(issues.map(i => i.fieldPath)).toContain('fromEmail'); expect(issues.map(i => i.fieldPath)).toContain('subject'); expect(issues.map(i => i.fieldPath)).toContain('body.html'); expect(issues.map(i => i.fieldPath)).toContain('body.text'); }); it('should handle arrays with expressions', () => { const parameters = { recipients: [ '{{ $json.email1 }}', '[email protected]', '={{ $json.email2 }}' ] }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); expect(issues).toHaveLength(1); expect(issues[0].fieldPath).toBe('recipients[0]'); expect(issues[0].correctedValue).toBe('={{ $json.email1 }}'); }); it('should handle nested objects', () => { const parameters = { config: { database: { host: '{{ $env.DB_HOST }}', port: 5432, name: 'mydb' } } }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); expect(issues).toHaveLength(1); expect(issues[0].fieldPath).toBe('config.database.host'); }); it('should skip circular references', () => { const circular: any = { a: 1 }; circular.self = circular; const parameters = { normal: '{{ $json.value }}', circular }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); // Should only find the issue in 'normal', not crash on circular expect(issues).toHaveLength(1); expect(issues[0].fieldPath).toBe('normal'); }); it('should handle maximum recursion depth', () => { // Create a deeply nested object (105 levels deep, exceeding the limit of 100) let deepObject: any = { value: '{{ $json.data }}' }; let current = deepObject; for (let i = 0; i < 105; i++) { current.nested = { value: `{{ $json.level${i} }}` }; current = current.nested; } const parameters = { deep: deepObject }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); // Should find expression format issues up to the depth limit const depthWarning = issues.find(i => i.explanation.includes('Maximum recursion depth')); expect(depthWarning).toBeTruthy(); expect(depthWarning?.severity).toBe('warning'); // Should still find some expression format errors before hitting the limit const formatErrors = issues.filter(i => i.issueType === 'missing-prefix'); expect(formatErrors.length).toBeGreaterThan(0); expect(formatErrors.length).toBeLessThanOrEqual(100); // Should not exceed the depth limit }); }); describe('formatErrorMessage', () => { const context = { nodeType: 'n8n-nodes-base.github', nodeName: 'Create Issue', nodeId: 'github-1' }; it('should format error message for missing prefix', () => { const issue = { fieldPath: 'title', currentValue: '{{ $json.title }}', correctedValue: '={{ $json.title }}', issueType: 'missing-prefix' as const, explanation: "Expression missing required '=' prefix.", severity: 'error' as const }; const message = ExpressionFormatValidator.formatErrorMessage(issue, context); expect(message).toContain("Expression format error in node 'Create Issue'"); expect(message).toContain('Field \'title\''); expect(message).toContain('Current (incorrect):'); expect(message).toContain('"title": "{{ $json.title }}"'); expect(message).toContain('Fixed (correct):'); expect(message).toContain('"title": "={{ $json.title }}"'); }); it('should format error message for resource locator', () => { const issue = { fieldPath: 'owner', currentValue: '{{ $vars.OWNER }}', correctedValue: { __rl: true, value: '={{ $vars.OWNER }}', mode: 'expression' }, issueType: 'needs-resource-locator' as const, explanation: 'Field needs resource locator format.', severity: 'error' as const }; const message = ExpressionFormatValidator.formatErrorMessage(issue, context); expect(message).toContain("Expression format error in node 'Create Issue'"); expect(message).toContain('Current (incorrect):'); expect(message).toContain('"owner": "{{ $vars.OWNER }}"'); expect(message).toContain('Fixed (correct):'); expect(message).toContain('"__rl": true'); expect(message).toContain('"value": "={{ $vars.OWNER }}"'); expect(message).toContain('"mode": "expression"'); }); }); describe('Real-world examples', () => { it('should validate Email Send node example', () => { const context = { nodeType: 'n8n-nodes-base.emailSend', nodeName: 'Error Handler', nodeId: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0' }; const parameters = { fromEmail: '{{ $env.ADMIN_EMAIL }}', toEmail: '[email protected]', subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', options: {} }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); expect(issues).toHaveLength(1); expect(issues[0].fieldPath).toBe('fromEmail'); expect(issues[0].correctedValue).toBe('={{ $env.ADMIN_EMAIL }}'); }); it('should validate GitHub node example', () => { const context = { nodeType: 'n8n-nodes-base.github', nodeName: 'Send Welcome Comment', nodeId: '3c742ca1-af8f-4d80-a47e-e68fb1ced491' }; const parameters = { operation: 'createComment', owner: '{{ $vars.GITHUB_OWNER }}', repository: '{{ $vars.GITHUB_REPO }}', issueNumber: null, body: '👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!\n\nThank you for creating this issue.' }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); expect(issues.length).toBeGreaterThan(0); expect(issues.some(i => i.fieldPath === 'owner')).toBe(true); expect(issues.some(i => i.fieldPath === 'repository')).toBe(true); expect(issues.some(i => i.fieldPath === 'body')).toBe(true); }); }); }); ```