This is page 57 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-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-diff-engine.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { WorkflowDiffEngine } from '@/services/workflow-diff-engine'; 3 | import { createWorkflow, WorkflowBuilder } from '@tests/utils/builders/workflow.builder'; 4 | import { 5 | WorkflowDiffRequest, 6 | WorkflowDiffOperation, 7 | AddNodeOperation, 8 | RemoveNodeOperation, 9 | UpdateNodeOperation, 10 | MoveNodeOperation, 11 | EnableNodeOperation, 12 | DisableNodeOperation, 13 | AddConnectionOperation, 14 | RemoveConnectionOperation, 15 | UpdateSettingsOperation, 16 | UpdateNameOperation, 17 | AddTagOperation, 18 | RemoveTagOperation, 19 | CleanStaleConnectionsOperation, 20 | ReplaceConnectionsOperation 21 | } from '@/types/workflow-diff'; 22 | import { Workflow } from '@/types/n8n-api'; 23 | 24 | describe('WorkflowDiffEngine', () => { 25 | let diffEngine: WorkflowDiffEngine; 26 | let baseWorkflow: Workflow; 27 | let builder: WorkflowBuilder; 28 | 29 | beforeEach(() => { 30 | diffEngine = new WorkflowDiffEngine(); 31 | 32 | // Create a base workflow with some nodes 33 | builder = createWorkflow('Test Workflow') 34 | .addWebhookNode({ id: 'webhook-1', name: 'Webhook' }) 35 | .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' }) 36 | .addSlackNode({ id: 'slack-1', name: 'Slack' }) 37 | .connect('webhook-1', 'http-1') 38 | .connect('http-1', 'slack-1') 39 | .addTags('test', 'automation'); 40 | 41 | baseWorkflow = builder.build() as Workflow; 42 | 43 | // Convert connections from ID-based to name-based (as n8n expects) 44 | const newConnections: any = {}; 45 | for (const [nodeId, outputs] of Object.entries(baseWorkflow.connections)) { 46 | const node = baseWorkflow.nodes.find((n: any) => n.id === nodeId); 47 | if (node) { 48 | newConnections[node.name] = {}; 49 | for (const [outputName, connections] of Object.entries(outputs)) { 50 | newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => 51 | conns.map((conn: any) => { 52 | const targetNode = baseWorkflow.nodes.find((n: any) => n.id === conn.node); 53 | return { 54 | ...conn, 55 | node: targetNode ? targetNode.name : conn.node 56 | }; 57 | }) 58 | ); 59 | } 60 | } 61 | } 62 | baseWorkflow.connections = newConnections; 63 | }); 64 | 65 | describe('Large Operation Batches', () => { 66 | it('should handle many operations successfully', async () => { 67 | // Test with 50 operations 68 | const operations = Array(50).fill(null).map((_: any, i: number) => ({ 69 | type: 'updateName', 70 | name: `Name ${i}` 71 | } as UpdateNameOperation)); 72 | 73 | const request: WorkflowDiffRequest = { 74 | id: 'test-workflow', 75 | operations 76 | }; 77 | 78 | const result = await diffEngine.applyDiff(baseWorkflow, request); 79 | 80 | expect(result.success).toBe(true); 81 | expect(result.operationsApplied).toBe(50); 82 | expect(result.workflow!.name).toBe('Name 49'); // Last operation wins 83 | }); 84 | 85 | it('should handle 100+ mixed operations', async () => { 86 | const operations: WorkflowDiffOperation[] = [ 87 | // Add 30 nodes 88 | ...Array(30).fill(null).map((_: any, i: number) => ({ 89 | type: 'addNode', 90 | node: { 91 | name: `Node${i}`, 92 | type: 'n8n-nodes-base.code', 93 | position: [i * 100, 300], 94 | parameters: {} 95 | } 96 | } as AddNodeOperation)), 97 | // Update names 30 times 98 | ...Array(30).fill(null).map((_: any, i: number) => ({ 99 | type: 'updateName', 100 | name: `Workflow Version ${i}` 101 | } as UpdateNameOperation)), 102 | // Add 40 tags 103 | ...Array(40).fill(null).map((_: any, i: number) => ({ 104 | type: 'addTag', 105 | tag: `tag${i}` 106 | } as AddTagOperation)) 107 | ]; 108 | 109 | const request: WorkflowDiffRequest = { 110 | id: 'test-workflow', 111 | operations 112 | }; 113 | 114 | const result = await diffEngine.applyDiff(baseWorkflow, request); 115 | 116 | expect(result.success).toBe(true); 117 | expect(result.operationsApplied).toBe(100); 118 | expect(result.workflow!.nodes.length).toBeGreaterThan(30); 119 | expect(result.workflow!.name).toBe('Workflow Version 29'); 120 | }); 121 | }); 122 | 123 | describe('AddNode Operation', () => { 124 | it('should add a new node successfully', async () => { 125 | const operation: AddNodeOperation = { 126 | type: 'addNode', 127 | node: { 128 | name: 'New Code Node', 129 | type: 'n8n-nodes-base.code', 130 | position: [800, 300], 131 | typeVersion: 2, 132 | parameters: { 133 | mode: 'runOnceForAllItems', 134 | language: 'javaScript', 135 | jsCode: 'return items;' 136 | } 137 | } 138 | }; 139 | 140 | const request: WorkflowDiffRequest = { 141 | id: 'test-workflow', 142 | operations: [operation] 143 | }; 144 | 145 | const result = await diffEngine.applyDiff(baseWorkflow, request); 146 | 147 | expect(result.success).toBe(true); 148 | expect(result.workflow!.nodes).toHaveLength(4); 149 | expect(result.workflow!.nodes[3].name).toBe('New Code Node'); 150 | expect(result.workflow!.nodes[3].type).toBe('n8n-nodes-base.code'); 151 | expect(result.workflow!.nodes[3].id).toBeDefined(); 152 | }); 153 | 154 | it('should reject duplicate node names', async () => { 155 | const operation: AddNodeOperation = { 156 | type: 'addNode', 157 | node: { 158 | name: 'Webhook', // Duplicate name 159 | type: 'n8n-nodes-base.webhook', 160 | position: [800, 300] 161 | } 162 | }; 163 | 164 | const request: WorkflowDiffRequest = { 165 | id: 'test-workflow', 166 | operations: [operation] 167 | }; 168 | 169 | const result = await diffEngine.applyDiff(baseWorkflow, request); 170 | 171 | expect(result.success).toBe(false); 172 | expect(result.errors![0].message).toContain('already exists'); 173 | }); 174 | 175 | it('should reject invalid node type format', async () => { 176 | const operation: AddNodeOperation = { 177 | type: 'addNode', 178 | node: { 179 | name: 'Invalid Node', 180 | type: 'webhook', // Missing package prefix 181 | position: [800, 300] 182 | } 183 | }; 184 | 185 | const request: WorkflowDiffRequest = { 186 | id: 'test-workflow', 187 | operations: [operation] 188 | }; 189 | 190 | const result = await diffEngine.applyDiff(baseWorkflow, request); 191 | 192 | expect(result.success).toBe(false); 193 | expect(result.errors![0].message).toContain('Invalid node type'); 194 | }); 195 | 196 | it('should correct nodes-base prefix to n8n-nodes-base', async () => { 197 | const operation: AddNodeOperation = { 198 | type: 'addNode', 199 | node: { 200 | name: 'Test Node', 201 | type: 'nodes-base.webhook', // Wrong prefix 202 | position: [800, 300] 203 | } 204 | }; 205 | 206 | const request: WorkflowDiffRequest = { 207 | id: 'test-workflow', 208 | operations: [operation] 209 | }; 210 | 211 | const result = await diffEngine.applyDiff(baseWorkflow, request); 212 | 213 | expect(result.success).toBe(false); 214 | expect(result.errors![0].message).toContain('Use "n8n-nodes-base.'); 215 | }); 216 | 217 | it('should generate node ID if not provided', async () => { 218 | const operation: AddNodeOperation = { 219 | type: 'addNode', 220 | node: { 221 | name: 'No ID Node', 222 | type: 'n8n-nodes-base.code', 223 | position: [800, 300] 224 | } 225 | }; 226 | 227 | const request: WorkflowDiffRequest = { 228 | id: 'test-workflow', 229 | operations: [operation] 230 | }; 231 | 232 | const result = await diffEngine.applyDiff(baseWorkflow, request); 233 | 234 | expect(result.success).toBe(true); 235 | expect(result.workflow!.nodes[3].id).toBeDefined(); 236 | expect(result.workflow!.nodes[3].id).toMatch(/^[0-9a-f-]+$/); 237 | }); 238 | }); 239 | 240 | describe('RemoveNode Operation', () => { 241 | it('should remove node by ID', async () => { 242 | const operation: RemoveNodeOperation = { 243 | type: 'removeNode', 244 | nodeId: 'http-1' 245 | }; 246 | 247 | const request: WorkflowDiffRequest = { 248 | id: 'test-workflow', 249 | operations: [operation] 250 | }; 251 | 252 | const result = await diffEngine.applyDiff(baseWorkflow, request); 253 | 254 | expect(result.success).toBe(true); 255 | expect(result.workflow!.nodes).toHaveLength(2); 256 | expect(result.workflow!.nodes.find((n: any) => n.id === 'http-1')).toBeUndefined(); 257 | }); 258 | 259 | it('should remove node by name', async () => { 260 | const operation: RemoveNodeOperation = { 261 | type: 'removeNode', 262 | nodeName: 'HTTP Request' 263 | }; 264 | 265 | const request: WorkflowDiffRequest = { 266 | id: 'test-workflow', 267 | operations: [operation] 268 | }; 269 | 270 | const result = await diffEngine.applyDiff(baseWorkflow, request); 271 | 272 | expect(result.success).toBe(true); 273 | expect(result.workflow!.nodes).toHaveLength(2); 274 | expect(result.workflow!.nodes.find((n: any) => n.name === 'HTTP Request')).toBeUndefined(); 275 | }); 276 | 277 | it('should clean up connections when removing node', async () => { 278 | const operation: RemoveNodeOperation = { 279 | type: 'removeNode', 280 | nodeId: 'http-1' 281 | }; 282 | 283 | const request: WorkflowDiffRequest = { 284 | id: 'test-workflow', 285 | operations: [operation] 286 | }; 287 | 288 | const result = await diffEngine.applyDiff(baseWorkflow, request); 289 | 290 | expect(result.success).toBe(true); 291 | expect(result.workflow!.connections['HTTP Request']).toBeUndefined(); 292 | // Check that connections from Webhook were cleaned up 293 | if (result.workflow!.connections['Webhook'] && result.workflow!.connections['Webhook'].main && result.workflow!.connections['Webhook'].main[0]) { 294 | expect(result.workflow!.connections['Webhook'].main[0]).toHaveLength(0); 295 | } else { 296 | // Webhook connections should be cleaned up entirely 297 | expect(result.workflow!.connections['Webhook']).toBeUndefined(); 298 | } 299 | }); 300 | 301 | it('should reject removing non-existent node', async () => { 302 | const operation: RemoveNodeOperation = { 303 | type: 'removeNode', 304 | nodeId: 'non-existent' 305 | }; 306 | 307 | const request: WorkflowDiffRequest = { 308 | id: 'test-workflow', 309 | operations: [operation] 310 | }; 311 | 312 | const result = await diffEngine.applyDiff(baseWorkflow, request); 313 | 314 | expect(result.success).toBe(false); 315 | expect(result.errors![0].message).toContain('Node not found'); 316 | }); 317 | }); 318 | 319 | describe('UpdateNode Operation', () => { 320 | it('should update node parameters', async () => { 321 | const operation: UpdateNodeOperation = { 322 | type: 'updateNode', 323 | nodeId: 'http-1', 324 | updates: { 325 | 'parameters.method': 'POST', 326 | 'parameters.url': 'https://new-api.example.com' 327 | } 328 | }; 329 | 330 | const request: WorkflowDiffRequest = { 331 | id: 'test-workflow', 332 | operations: [operation] 333 | }; 334 | 335 | const result = await diffEngine.applyDiff(baseWorkflow, request); 336 | 337 | expect(result.success).toBe(true); 338 | const updatedNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1'); 339 | expect(updatedNode!.parameters.method).toBe('POST'); 340 | expect(updatedNode!.parameters.url).toBe('https://new-api.example.com'); 341 | }); 342 | 343 | it('should update nested properties using dot notation', async () => { 344 | const operation: UpdateNodeOperation = { 345 | type: 'updateNode', 346 | nodeName: 'Slack', 347 | updates: { 348 | 'parameters.resource': 'channel', 349 | 'parameters.operation': 'create', 350 | 'credentials.slackApi.name': 'New Slack Account' 351 | } 352 | }; 353 | 354 | const request: WorkflowDiffRequest = { 355 | id: 'test-workflow', 356 | operations: [operation] 357 | }; 358 | 359 | const result = await diffEngine.applyDiff(baseWorkflow, request); 360 | 361 | expect(result.success).toBe(true); 362 | const updatedNode = result.workflow!.nodes.find((n: any) => n.name === 'Slack'); 363 | expect(updatedNode!.parameters.resource).toBe('channel'); 364 | expect(updatedNode!.parameters.operation).toBe('create'); 365 | expect((updatedNode!.credentials as any).slackApi.name).toBe('New Slack Account'); 366 | }); 367 | 368 | it('should reject updating non-existent node', async () => { 369 | const operation: UpdateNodeOperation = { 370 | type: 'updateNode', 371 | nodeId: 'non-existent', 372 | updates: { 373 | 'parameters.test': 'value' 374 | } 375 | }; 376 | 377 | const request: WorkflowDiffRequest = { 378 | id: 'test-workflow', 379 | operations: [operation] 380 | }; 381 | 382 | const result = await diffEngine.applyDiff(baseWorkflow, request); 383 | 384 | expect(result.success).toBe(false); 385 | expect(result.errors![0].message).toContain('Node not found'); 386 | }); 387 | }); 388 | 389 | describe('MoveNode Operation', () => { 390 | it('should move node to new position', async () => { 391 | const operation: MoveNodeOperation = { 392 | type: 'moveNode', 393 | nodeId: 'http-1', 394 | position: [1000, 500] 395 | }; 396 | 397 | const request: WorkflowDiffRequest = { 398 | id: 'test-workflow', 399 | operations: [operation] 400 | }; 401 | 402 | const result = await diffEngine.applyDiff(baseWorkflow, request); 403 | 404 | expect(result.success).toBe(true); 405 | const movedNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1'); 406 | expect(movedNode!.position).toEqual([1000, 500]); 407 | }); 408 | 409 | it('should move node by name', async () => { 410 | const operation: MoveNodeOperation = { 411 | type: 'moveNode', 412 | nodeName: 'Webhook', 413 | position: [100, 100] 414 | }; 415 | 416 | const request: WorkflowDiffRequest = { 417 | id: 'test-workflow', 418 | operations: [operation] 419 | }; 420 | 421 | const result = await diffEngine.applyDiff(baseWorkflow, request); 422 | 423 | expect(result.success).toBe(true); 424 | const movedNode = result.workflow!.nodes.find((n: any) => n.name === 'Webhook'); 425 | expect(movedNode!.position).toEqual([100, 100]); 426 | }); 427 | }); 428 | 429 | describe('Enable/Disable Node Operations', () => { 430 | it('should disable a node', async () => { 431 | const operation: DisableNodeOperation = { 432 | type: 'disableNode', 433 | nodeId: 'http-1' 434 | }; 435 | 436 | const request: WorkflowDiffRequest = { 437 | id: 'test-workflow', 438 | operations: [operation] 439 | }; 440 | 441 | const result = await diffEngine.applyDiff(baseWorkflow, request); 442 | 443 | expect(result.success).toBe(true); 444 | const disabledNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1'); 445 | expect(disabledNode!.disabled).toBe(true); 446 | }); 447 | 448 | it('should enable a disabled node', async () => { 449 | // First disable the node 450 | baseWorkflow.nodes[1].disabled = true; 451 | 452 | const operation: EnableNodeOperation = { 453 | type: 'enableNode', 454 | nodeId: 'http-1' 455 | }; 456 | 457 | const request: WorkflowDiffRequest = { 458 | id: 'test-workflow', 459 | operations: [operation] 460 | }; 461 | 462 | const result = await diffEngine.applyDiff(baseWorkflow, request); 463 | 464 | expect(result.success).toBe(true); 465 | const enabledNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1'); 466 | expect(enabledNode!.disabled).toBe(false); 467 | }); 468 | }); 469 | 470 | describe('AddConnection Operation', () => { 471 | it('should add a new connection', async () => { 472 | // First add a new node to connect to 473 | const addNodeOp: AddNodeOperation = { 474 | type: 'addNode', 475 | node: { 476 | name: 'Code', 477 | type: 'n8n-nodes-base.code', 478 | position: [1000, 300] 479 | } 480 | }; 481 | 482 | const addConnectionOp: AddConnectionOperation = { 483 | type: 'addConnection', 484 | source: 'slack-1', 485 | target: 'Code' 486 | }; 487 | 488 | const request: WorkflowDiffRequest = { 489 | id: 'test-workflow', 490 | operations: [addNodeOp, addConnectionOp] 491 | }; 492 | 493 | const result = await diffEngine.applyDiff(baseWorkflow, request); 494 | 495 | expect(result.success).toBe(true); 496 | expect(result.workflow!.connections['Slack']).toBeDefined(); 497 | expect(result.workflow!.connections['Slack'].main[0]).toHaveLength(1); 498 | expect(result.workflow!.connections['Slack'].main[0][0].node).toBe('Code'); 499 | }); 500 | 501 | it('should reject duplicate connections', async () => { 502 | const operation: AddConnectionOperation = { 503 | type: 'addConnection', 504 | source: 'Webhook', // Use node name not ID 505 | target: 'HTTP Request' // Use node name not ID 506 | }; 507 | 508 | const request: WorkflowDiffRequest = { 509 | id: 'test-workflow', 510 | operations: [operation] 511 | }; 512 | 513 | const result = await diffEngine.applyDiff(baseWorkflow, request); 514 | 515 | expect(result.success).toBe(false); 516 | expect(result.errors![0].message).toContain('Connection already exists'); 517 | }); 518 | 519 | it('should reject connection to non-existent source node', async () => { 520 | const operation: AddConnectionOperation = { 521 | type: 'addConnection', 522 | source: 'non-existent', 523 | target: 'http-1' 524 | }; 525 | 526 | const request: WorkflowDiffRequest = { 527 | id: 'test-workflow', 528 | operations: [operation] 529 | }; 530 | 531 | const result = await diffEngine.applyDiff(baseWorkflow, request); 532 | 533 | expect(result.success).toBe(false); 534 | expect(result.errors![0].message).toContain('Source node not found'); 535 | }); 536 | 537 | it('should reject connection to non-existent target node', async () => { 538 | const operation: AddConnectionOperation = { 539 | type: 'addConnection', 540 | source: 'webhook-1', 541 | target: 'non-existent' 542 | }; 543 | 544 | const request: WorkflowDiffRequest = { 545 | id: 'test-workflow', 546 | operations: [operation] 547 | }; 548 | 549 | const result = await diffEngine.applyDiff(baseWorkflow, request); 550 | 551 | expect(result.success).toBe(false); 552 | expect(result.errors![0].message).toContain('Target node not found'); 553 | }); 554 | 555 | it('should support custom output and input types', async () => { 556 | // Add an IF node that has multiple outputs 557 | const addNodeOp: AddNodeOperation = { 558 | type: 'addNode', 559 | node: { 560 | name: 'IF', 561 | type: 'n8n-nodes-base.if', 562 | position: [600, 400] 563 | } 564 | }; 565 | 566 | const addConnectionOp: AddConnectionOperation = { 567 | type: 'addConnection', 568 | source: 'IF', 569 | target: 'slack-1', 570 | sourceOutput: 'false', 571 | targetInput: 'main', 572 | sourceIndex: 0, 573 | targetIndex: 0 574 | }; 575 | 576 | const request: WorkflowDiffRequest = { 577 | id: 'test-workflow', 578 | operations: [addNodeOp, addConnectionOp] 579 | }; 580 | 581 | const result = await diffEngine.applyDiff(baseWorkflow, request); 582 | 583 | expect(result.success).toBe(true); 584 | expect(result.workflow!.connections['IF'].false).toBeDefined(); 585 | expect(result.workflow!.connections['IF'].false[0][0].node).toBe('Slack'); 586 | }); 587 | 588 | it('should reject addConnection with wrong parameter sourceNodeId instead of source (Issue #249)', async () => { 589 | const operation: any = { 590 | type: 'addConnection', 591 | sourceNodeId: 'webhook-1', // Wrong parameter name! 592 | target: 'http-1' 593 | }; 594 | 595 | const request: WorkflowDiffRequest = { 596 | id: 'test-workflow', 597 | operations: [operation] 598 | }; 599 | 600 | const result = await diffEngine.applyDiff(baseWorkflow, request); 601 | 602 | expect(result.success).toBe(false); 603 | expect(result.errors![0].message).toContain('Invalid parameter(s): sourceNodeId'); 604 | expect(result.errors![0].message).toContain("Use 'source' and 'target' instead"); 605 | }); 606 | 607 | it('should reject addConnection with wrong parameter targetNodeId instead of target (Issue #249)', async () => { 608 | const operation: any = { 609 | type: 'addConnection', 610 | source: 'webhook-1', 611 | targetNodeId: 'http-1' // Wrong parameter name! 612 | }; 613 | 614 | const request: WorkflowDiffRequest = { 615 | id: 'test-workflow', 616 | operations: [operation] 617 | }; 618 | 619 | const result = await diffEngine.applyDiff(baseWorkflow, request); 620 | 621 | expect(result.success).toBe(false); 622 | expect(result.errors![0].message).toContain('Invalid parameter(s): targetNodeId'); 623 | expect(result.errors![0].message).toContain("Use 'source' and 'target' instead"); 624 | }); 625 | 626 | it('should reject addConnection with both wrong parameters (Issue #249)', async () => { 627 | const operation: any = { 628 | type: 'addConnection', 629 | sourceNodeId: 'webhook-1', // Wrong! 630 | targetNodeId: 'http-1' // Wrong! 631 | }; 632 | 633 | const request: WorkflowDiffRequest = { 634 | id: 'test-workflow', 635 | operations: [operation] 636 | }; 637 | 638 | const result = await diffEngine.applyDiff(baseWorkflow, request); 639 | 640 | expect(result.success).toBe(false); 641 | expect(result.errors![0].message).toContain('Invalid parameter(s): sourceNodeId, targetNodeId'); 642 | expect(result.errors![0].message).toContain("Use 'source' and 'target' instead"); 643 | }); 644 | 645 | it('should show helpful error with available nodes when source is missing (Issue #249)', async () => { 646 | const operation: any = { 647 | type: 'addConnection', 648 | // source is missing entirely 649 | target: 'http-1' 650 | }; 651 | 652 | const request: WorkflowDiffRequest = { 653 | id: 'test-workflow', 654 | operations: [operation] 655 | }; 656 | 657 | const result = await diffEngine.applyDiff(baseWorkflow, request); 658 | 659 | expect(result.success).toBe(false); 660 | expect(result.errors![0].message).toContain("Missing required parameter 'source'"); 661 | expect(result.errors![0].message).toContain("not 'sourceNodeId'"); 662 | }); 663 | 664 | it('should show helpful error with available nodes when target is missing (Issue #249)', async () => { 665 | const operation: any = { 666 | type: 'addConnection', 667 | source: 'webhook-1', 668 | // target is missing entirely 669 | }; 670 | 671 | const request: WorkflowDiffRequest = { 672 | id: 'test-workflow', 673 | operations: [operation] 674 | }; 675 | 676 | const result = await diffEngine.applyDiff(baseWorkflow, request); 677 | 678 | expect(result.success).toBe(false); 679 | expect(result.errors![0].message).toContain("Missing required parameter 'target'"); 680 | expect(result.errors![0].message).toContain("not 'targetNodeId'"); 681 | }); 682 | 683 | it('should list available nodes when source node not found (Issue #249)', async () => { 684 | const operation: AddConnectionOperation = { 685 | type: 'addConnection', 686 | source: 'non-existent-node', 687 | target: 'http-1' 688 | }; 689 | 690 | const request: WorkflowDiffRequest = { 691 | id: 'test-workflow', 692 | operations: [operation] 693 | }; 694 | 695 | const result = await diffEngine.applyDiff(baseWorkflow, request); 696 | 697 | expect(result.success).toBe(false); 698 | expect(result.errors![0].message).toContain('Source node not found: "non-existent-node"'); 699 | expect(result.errors![0].message).toContain('Available nodes:'); 700 | expect(result.errors![0].message).toContain('Webhook'); 701 | expect(result.errors![0].message).toContain('HTTP Request'); 702 | expect(result.errors![0].message).toContain('Slack'); 703 | }); 704 | 705 | it('should list available nodes when target node not found (Issue #249)', async () => { 706 | const operation: AddConnectionOperation = { 707 | type: 'addConnection', 708 | source: 'webhook-1', 709 | target: 'non-existent-node' 710 | }; 711 | 712 | const request: WorkflowDiffRequest = { 713 | id: 'test-workflow', 714 | operations: [operation] 715 | }; 716 | 717 | const result = await diffEngine.applyDiff(baseWorkflow, request); 718 | 719 | expect(result.success).toBe(false); 720 | expect(result.errors![0].message).toContain('Target node not found: "non-existent-node"'); 721 | expect(result.errors![0].message).toContain('Available nodes:'); 722 | expect(result.errors![0].message).toContain('Webhook'); 723 | expect(result.errors![0].message).toContain('HTTP Request'); 724 | expect(result.errors![0].message).toContain('Slack'); 725 | }); 726 | }); 727 | 728 | describe('RemoveConnection Operation', () => { 729 | it('should remove an existing connection', async () => { 730 | const operation: RemoveConnectionOperation = { 731 | type: 'removeConnection', 732 | source: 'Webhook', // Use node name 733 | target: 'HTTP Request' // Use node name 734 | }; 735 | 736 | const request: WorkflowDiffRequest = { 737 | id: 'test-workflow', 738 | operations: [operation] 739 | }; 740 | 741 | const result = await diffEngine.applyDiff(baseWorkflow, request); 742 | 743 | expect(result.success).toBe(true); 744 | // After removing the connection, the array should be empty or cleaned up 745 | if (result.workflow!.connections['Webhook']) { 746 | if (result.workflow!.connections['Webhook'].main && result.workflow!.connections['Webhook'].main.length > 0) { 747 | expect(result.workflow!.connections['Webhook'].main[0]).toHaveLength(0); 748 | } else { 749 | expect(result.workflow!.connections['Webhook'].main).toHaveLength(0); 750 | } 751 | } else { 752 | // Connection was cleaned up entirely 753 | expect(result.workflow!.connections['Webhook']).toBeUndefined(); 754 | } 755 | }); 756 | 757 | it('should reject removing non-existent connection', async () => { 758 | const operation: RemoveConnectionOperation = { 759 | type: 'removeConnection', 760 | source: 'Slack', // Use node name 761 | target: 'Webhook' // Use node name 762 | }; 763 | 764 | const request: WorkflowDiffRequest = { 765 | id: 'test-workflow', 766 | operations: [operation] 767 | }; 768 | 769 | const result = await diffEngine.applyDiff(baseWorkflow, request); 770 | 771 | expect(result.success).toBe(false); 772 | expect(result.errors![0].message).toContain('No connections found'); 773 | }); 774 | }); 775 | 776 | 777 | describe('RewireConnection Operation (Phase 1)', () => { 778 | it('should rewire connection from one target to another', async () => { 779 | // Setup: Create a connection Webhook → HTTP Request 780 | // Then rewire it to Webhook → Slack instead 781 | const rewireOp: any = { 782 | type: 'rewireConnection', 783 | source: 'Webhook', 784 | from: 'HTTP Request', 785 | to: 'Slack' 786 | }; 787 | 788 | const request: WorkflowDiffRequest = { 789 | id: 'test-workflow', 790 | operations: [rewireOp] 791 | }; 792 | 793 | const result = await diffEngine.applyDiff(baseWorkflow, request); 794 | 795 | expect(result.success).toBe(true); 796 | expect(result.workflow).toBeDefined(); 797 | 798 | // Old connection should be removed 799 | const webhookConnections = result.workflow!.connections['Webhook']['main'][0]; 800 | expect(webhookConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false); 801 | 802 | // New connection should exist 803 | expect(webhookConnections.some((c: any) => c.node === 'Slack')).toBe(true); 804 | }); 805 | 806 | it('should rewire connection with specified sourceOutput', async () => { 807 | // Add IF node with connection on 'true' output 808 | const addNode: AddNodeOperation = { 809 | type: 'addNode', 810 | node: { 811 | name: 'IF', 812 | type: 'n8n-nodes-base.if', 813 | position: [600, 300] 814 | } 815 | }; 816 | 817 | const addConn: AddConnectionOperation = { 818 | type: 'addConnection', 819 | source: 'IF', 820 | target: 'HTTP Request', 821 | sourceOutput: 'true' 822 | }; 823 | 824 | const rewire: any = { 825 | type: 'rewireConnection', 826 | source: 'IF', 827 | from: 'HTTP Request', 828 | to: 'Slack', 829 | sourceOutput: 'true' 830 | }; 831 | 832 | const request: WorkflowDiffRequest = { 833 | id: 'test-workflow', 834 | operations: [addNode, addConn, rewire] 835 | }; 836 | 837 | const result = await diffEngine.applyDiff(baseWorkflow, request); 838 | 839 | expect(result.success).toBe(true); 840 | 841 | // Verify rewiring on 'true' output 842 | const trueConnections = result.workflow!.connections['IF']['true'][0]; 843 | expect(trueConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false); 844 | expect(trueConnections.some((c: any) => c.node === 'Slack')).toBe(true); 845 | }); 846 | 847 | it('should preserve other parallel connections when rewiring', async () => { 848 | // Setup: Webhook connects to both HTTP Request (in baseWorkflow) and Slack (added here) 849 | // Add a Set node, then rewire HTTP Request → Set 850 | // Slack connection should remain unchanged 851 | 852 | // Add Slack connection in parallel 853 | const addSlackConn: AddConnectionOperation = { 854 | type: 'addConnection', 855 | source: 'Webhook', 856 | target: 'Slack' 857 | }; 858 | 859 | // Add Set node to rewire to 860 | const addSetNode: AddNodeOperation = { 861 | type: 'addNode', 862 | node: { 863 | name: 'Set', 864 | type: 'n8n-nodes-base.set', 865 | position: [800, 300] 866 | } 867 | }; 868 | 869 | // Rewire HTTP Request → Set 870 | const rewire: any = { 871 | type: 'rewireConnection', 872 | source: 'Webhook', 873 | from: 'HTTP Request', 874 | to: 'Set' 875 | }; 876 | 877 | const request: WorkflowDiffRequest = { 878 | id: 'test-workflow', 879 | operations: [addSlackConn, addSetNode, rewire] 880 | }; 881 | 882 | const result = await diffEngine.applyDiff(baseWorkflow, request); 883 | 884 | expect(result.success).toBe(true); 885 | 886 | const webhookConnections = result.workflow!.connections['Webhook']['main'][0]; 887 | 888 | // HTTP Request should be removed 889 | expect(webhookConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false); 890 | 891 | // Set should be added 892 | expect(webhookConnections.some((c: any) => c.node === 'Set')).toBe(true); 893 | 894 | // Slack should still be there (parallel connection preserved) 895 | expect(webhookConnections.some((c: any) => c.node === 'Slack')).toBe(true); 896 | }); 897 | 898 | it('should reject rewireConnection when source node not found', async () => { 899 | const rewire: any = { 900 | type: 'rewireConnection', 901 | source: 'NonExistent', 902 | from: 'HTTP Request', 903 | to: 'Slack' 904 | }; 905 | 906 | const request: WorkflowDiffRequest = { 907 | id: 'test-workflow', 908 | operations: [rewire] 909 | }; 910 | 911 | const result = await diffEngine.applyDiff(baseWorkflow, request); 912 | 913 | expect(result.success).toBe(false); 914 | expect(result.errors).toBeDefined(); 915 | expect(result.errors![0].message).toContain('Source node not found'); 916 | expect(result.errors![0].message).toContain('NonExistent'); 917 | expect(result.errors![0].message).toContain('Available nodes'); 918 | }); 919 | 920 | it('should reject rewireConnection when "from" node not found', async () => { 921 | const rewire: any = { 922 | type: 'rewireConnection', 923 | source: 'Webhook', 924 | from: 'NonExistent', 925 | to: 'Slack' 926 | }; 927 | 928 | const request: WorkflowDiffRequest = { 929 | id: 'test-workflow', 930 | operations: [rewire] 931 | }; 932 | 933 | const result = await diffEngine.applyDiff(baseWorkflow, request); 934 | 935 | expect(result.success).toBe(false); 936 | expect(result.errors).toBeDefined(); 937 | expect(result.errors![0].message).toContain('"From" node not found'); 938 | expect(result.errors![0].message).toContain('NonExistent'); 939 | }); 940 | 941 | it('should reject rewireConnection when "to" node not found', async () => { 942 | const rewire: any = { 943 | type: 'rewireConnection', 944 | source: 'Webhook', 945 | from: 'HTTP Request', 946 | to: 'NonExistent' 947 | }; 948 | 949 | const request: WorkflowDiffRequest = { 950 | id: 'test-workflow', 951 | operations: [rewire] 952 | }; 953 | 954 | const result = await diffEngine.applyDiff(baseWorkflow, request); 955 | 956 | expect(result.success).toBe(false); 957 | expect(result.errors).toBeDefined(); 958 | expect(result.errors![0].message).toContain('"To" node not found'); 959 | expect(result.errors![0].message).toContain('NonExistent'); 960 | }); 961 | 962 | it('should reject rewireConnection when connection does not exist', async () => { 963 | // Slack node exists but doesn't have any outgoing connections 964 | // So this should fail with "No connections found" error 965 | const rewire: any = { 966 | type: 'rewireConnection', 967 | source: 'Slack', // Slack has no outgoing connections in baseWorkflow 968 | from: 'HTTP Request', 969 | to: 'Webhook' // Use existing node 970 | }; 971 | 972 | const request: WorkflowDiffRequest = { 973 | id: 'test-workflow', 974 | operations: [rewire] 975 | }; 976 | 977 | const result = await diffEngine.applyDiff(baseWorkflow, request); 978 | 979 | expect(result.success).toBe(false); 980 | expect(result.errors).toBeDefined(); 981 | expect(result.errors![0].message).toContain('No connections found from'); 982 | expect(result.errors![0].message).toContain('Slack'); 983 | }); 984 | 985 | it('should handle rewiring IF node branches correctly', async () => { 986 | // Add IF node with true/false branches 987 | const addIF: AddNodeOperation = { 988 | type: 'addNode', 989 | node: { 990 | name: 'IF', 991 | type: 'n8n-nodes-base.if', 992 | position: [600, 300] 993 | } 994 | }; 995 | 996 | const addSuccess: AddNodeOperation = { 997 | type: 'addNode', 998 | node: { 999 | name: 'SuccessHandler', 1000 | type: 'n8n-nodes-base.set', 1001 | position: [800, 200] 1002 | } 1003 | }; 1004 | 1005 | const addError: AddNodeOperation = { 1006 | type: 'addNode', 1007 | node: { 1008 | name: 'ErrorHandler', 1009 | type: 'n8n-nodes-base.set', 1010 | position: [800, 400] 1011 | } 1012 | }; 1013 | 1014 | const connectTrue: AddConnectionOperation = { 1015 | type: 'addConnection', 1016 | source: 'IF', 1017 | target: 'SuccessHandler', 1018 | sourceOutput: 'true' 1019 | }; 1020 | 1021 | const connectFalse: AddConnectionOperation = { 1022 | type: 'addConnection', 1023 | source: 'IF', 1024 | target: 'ErrorHandler', 1025 | sourceOutput: 'false' 1026 | }; 1027 | 1028 | // Rewire the false branch to go to SuccessHandler instead 1029 | const rewireFalse: any = { 1030 | type: 'rewireConnection', 1031 | source: 'IF', 1032 | from: 'ErrorHandler', 1033 | to: 'Slack', 1034 | sourceOutput: 'false' 1035 | }; 1036 | 1037 | const request: WorkflowDiffRequest = { 1038 | id: 'test-workflow', 1039 | operations: [addIF, addSuccess, addError, connectTrue, connectFalse, rewireFalse] 1040 | }; 1041 | 1042 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1043 | 1044 | expect(result.success).toBe(true); 1045 | 1046 | // True branch should still point to SuccessHandler 1047 | expect(result.workflow!.connections['IF']['true'][0][0].node).toBe('SuccessHandler'); 1048 | 1049 | // False branch should now point to Slack 1050 | expect(result.workflow!.connections['IF']['false'][0][0].node).toBe('Slack'); 1051 | }); 1052 | }); 1053 | 1054 | describe('Smart Parameters (Phase 1)', () => { 1055 | it('should use branch="true" for IF node connections', async () => { 1056 | // Add IF node 1057 | const addIF: any = { 1058 | type: 'addNode', 1059 | node: { 1060 | name: 'IF', 1061 | type: 'n8n-nodes-base.if', 1062 | position: [400, 300] 1063 | } 1064 | }; 1065 | 1066 | // Add TrueHandler node (use unique name) 1067 | const addTrueHandler: any = { 1068 | type: 'addNode', 1069 | node: { 1070 | name: 'TrueHandler', 1071 | type: 'n8n-nodes-base.set', 1072 | position: [600, 300] 1073 | } 1074 | }; 1075 | 1076 | // Connect IF to TrueHandler using smart branch parameter 1077 | const connectWithBranch: any = { 1078 | type: 'addConnection', 1079 | source: 'IF', 1080 | target: 'TrueHandler', 1081 | branch: 'true' // Smart parameter instead of sourceOutput: 'true' 1082 | }; 1083 | 1084 | const request: WorkflowDiffRequest = { 1085 | id: 'test-workflow', 1086 | operations: [addIF, addTrueHandler, connectWithBranch] 1087 | }; 1088 | 1089 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1090 | 1091 | expect(result.success).toBe(true); 1092 | expect(result.workflow).toBeDefined(); 1093 | 1094 | // Should create connection on 'main' output, index 0 (true branch) 1095 | expect(result.workflow!.connections['IF']['main']).toBeDefined(); 1096 | expect(result.workflow!.connections['IF']['main'][0]).toBeDefined(); 1097 | expect(result.workflow!.connections['IF']['main'][0][0].node).toBe('TrueHandler'); 1098 | }); 1099 | 1100 | it('should use branch="false" for IF node connections', async () => { 1101 | const addIF: any = { 1102 | type: 'addNode', 1103 | node: { 1104 | name: 'IF', 1105 | type: 'n8n-nodes-base.if', 1106 | position: [400, 300] 1107 | } 1108 | }; 1109 | 1110 | const addFalseHandler: any = { 1111 | type: 'addNode', 1112 | node: { 1113 | name: 'FalseHandler', 1114 | type: 'n8n-nodes-base.set', 1115 | position: [600, 300] 1116 | } 1117 | }; 1118 | 1119 | const connectWithBranch: any = { 1120 | type: 'addConnection', 1121 | source: 'IF', 1122 | target: 'FalseHandler', 1123 | branch: 'false' // Smart parameter for false branch 1124 | }; 1125 | 1126 | const request: WorkflowDiffRequest = { 1127 | id: 'test-workflow', 1128 | operations: [addIF, addFalseHandler, connectWithBranch] 1129 | }; 1130 | 1131 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1132 | 1133 | expect(result.success).toBe(true); 1134 | 1135 | // Should create connection on 'main' output, index 1 (false branch) 1136 | expect(result.workflow!.connections['IF']['main']).toBeDefined(); 1137 | expect(result.workflow!.connections['IF']['main'][1]).toBeDefined(); 1138 | expect(result.workflow!.connections['IF']['main'][1][0].node).toBe('FalseHandler'); 1139 | }); 1140 | 1141 | it('should use case parameter for Switch node connections', async () => { 1142 | // Add Switch node 1143 | const addSwitch: any = { 1144 | type: 'addNode', 1145 | node: { 1146 | name: 'Switch', 1147 | type: 'n8n-nodes-base.switch', 1148 | position: [400, 300] 1149 | } 1150 | }; 1151 | 1152 | // Add handler nodes 1153 | const addCase0: any = { 1154 | type: 'addNode', 1155 | node: { 1156 | name: 'Case0Handler', 1157 | type: 'n8n-nodes-base.set', 1158 | position: [600, 200] 1159 | } 1160 | }; 1161 | 1162 | const addCase1: any = { 1163 | type: 'addNode', 1164 | node: { 1165 | name: 'Case1Handler', 1166 | type: 'n8n-nodes-base.set', 1167 | position: [600, 300] 1168 | } 1169 | }; 1170 | 1171 | const addCase2: any = { 1172 | type: 'addNode', 1173 | node: { 1174 | name: 'Case2Handler', 1175 | type: 'n8n-nodes-base.set', 1176 | position: [600, 400] 1177 | } 1178 | }; 1179 | 1180 | // Connect using case parameter 1181 | const connectCase0: any = { 1182 | type: 'addConnection', 1183 | source: 'Switch', 1184 | target: 'Case0Handler', 1185 | case: 0 // Smart parameter instead of sourceIndex: 0 1186 | }; 1187 | 1188 | const connectCase1: any = { 1189 | type: 'addConnection', 1190 | source: 'Switch', 1191 | target: 'Case1Handler', 1192 | case: 1 // Smart parameter instead of sourceIndex: 1 1193 | }; 1194 | 1195 | const connectCase2: any = { 1196 | type: 'addConnection', 1197 | source: 'Switch', 1198 | target: 'Case2Handler', 1199 | case: 2 // Smart parameter instead of sourceIndex: 2 1200 | }; 1201 | 1202 | const request: WorkflowDiffRequest = { 1203 | id: 'test-workflow', 1204 | operations: [addSwitch, addCase0, addCase1, addCase2, connectCase0, connectCase1, connectCase2] 1205 | }; 1206 | 1207 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1208 | 1209 | expect(result.success).toBe(true); 1210 | 1211 | // All cases should be routed correctly 1212 | expect(result.workflow!.connections['Switch']['main'][0][0].node).toBe('Case0Handler'); 1213 | expect(result.workflow!.connections['Switch']['main'][1][0].node).toBe('Case1Handler'); 1214 | expect(result.workflow!.connections['Switch']['main'][2][0].node).toBe('Case2Handler'); 1215 | }); 1216 | 1217 | it('should use branch parameter with rewireConnection', async () => { 1218 | // Setup: Create IF node with true/false branches 1219 | const addIF: any = { 1220 | type: 'addNode', 1221 | node: { 1222 | name: 'IFRewire', 1223 | type: 'n8n-nodes-base.if', 1224 | position: [400, 300] 1225 | } 1226 | }; 1227 | 1228 | const addSuccess: any = { 1229 | type: 'addNode', 1230 | node: { 1231 | name: 'SuccessHandler', 1232 | type: 'n8n-nodes-base.set', 1233 | position: [600, 200] 1234 | } 1235 | }; 1236 | 1237 | const addNewSuccess: any = { 1238 | type: 'addNode', 1239 | node: { 1240 | name: 'NewSuccessHandler', 1241 | type: 'n8n-nodes-base.set', 1242 | position: [600, 250] 1243 | } 1244 | }; 1245 | 1246 | // Initial connection 1247 | const initialConn: any = { 1248 | type: 'addConnection', 1249 | source: 'IFRewire', 1250 | target: 'SuccessHandler', 1251 | branch: 'true' 1252 | }; 1253 | 1254 | // Rewire using branch parameter 1255 | const rewire: any = { 1256 | type: 'rewireConnection', 1257 | source: 'IFRewire', 1258 | from: 'SuccessHandler', 1259 | to: 'NewSuccessHandler', 1260 | branch: 'true' // Smart parameter 1261 | }; 1262 | 1263 | const request: WorkflowDiffRequest = { 1264 | id: 'test-workflow', 1265 | operations: [addIF, addSuccess, addNewSuccess, initialConn, rewire] 1266 | }; 1267 | 1268 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1269 | 1270 | expect(result.success).toBe(true); 1271 | 1272 | // Should rewire the true branch (main output, index 0) 1273 | expect(result.workflow!.connections['IFRewire']['main']).toBeDefined(); 1274 | expect(result.workflow!.connections['IFRewire']['main'][0]).toBeDefined(); 1275 | expect(result.workflow!.connections['IFRewire']['main'][0][0].node).toBe('NewSuccessHandler'); 1276 | }); 1277 | 1278 | it('should use case parameter with rewireConnection', async () => { 1279 | const addSwitch: any = { 1280 | type: 'addNode', 1281 | node: { 1282 | name: 'Switch', 1283 | type: 'n8n-nodes-base.switch', 1284 | position: [400, 300] 1285 | } 1286 | }; 1287 | 1288 | const addCase1: any = { 1289 | type: 'addNode', 1290 | node: { 1291 | name: 'Case1Handler', 1292 | type: 'n8n-nodes-base.set', 1293 | position: [600, 300] 1294 | } 1295 | }; 1296 | 1297 | const addNewCase1: any = { 1298 | type: 'addNode', 1299 | node: { 1300 | name: 'NewCase1Handler', 1301 | type: 'n8n-nodes-base.slack', 1302 | position: [600, 350] 1303 | } 1304 | }; 1305 | 1306 | const initialConn: any = { 1307 | type: 'addConnection', 1308 | source: 'Switch', 1309 | target: 'Case1Handler', 1310 | case: 1 1311 | }; 1312 | 1313 | const rewire: any = { 1314 | type: 'rewireConnection', 1315 | source: 'Switch', 1316 | from: 'Case1Handler', 1317 | to: 'NewCase1Handler', 1318 | case: 1 // Smart parameter 1319 | }; 1320 | 1321 | const request: WorkflowDiffRequest = { 1322 | id: 'test-workflow', 1323 | operations: [addSwitch, addCase1, addNewCase1, initialConn, rewire] 1324 | }; 1325 | 1326 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1327 | 1328 | expect(result.success).toBe(true); 1329 | 1330 | // Should rewire case 1 1331 | expect(result.workflow!.connections['Switch']['main'][1][0].node).toBe('NewCase1Handler'); 1332 | }); 1333 | 1334 | it('should not override explicit sourceOutput with branch parameter', async () => { 1335 | const addIF: any = { 1336 | type: 'addNode', 1337 | node: { 1338 | name: 'IFOverride', 1339 | type: 'n8n-nodes-base.if', 1340 | position: [400, 300] 1341 | } 1342 | }; 1343 | 1344 | const addHandler: any = { 1345 | type: 'addNode', 1346 | node: { 1347 | name: 'OverrideHandler', 1348 | type: 'n8n-nodes-base.set', 1349 | position: [600, 300] 1350 | } 1351 | }; 1352 | 1353 | // Both branch and sourceOutput provided - sourceOutput should win 1354 | const connectWithBoth: any = { 1355 | type: 'addConnection', 1356 | source: 'IFOverride', 1357 | target: 'OverrideHandler', 1358 | branch: 'true', // Smart parameter suggests 'true' 1359 | sourceOutput: 'false' // Explicit parameter should override 1360 | }; 1361 | 1362 | const request: WorkflowDiffRequest = { 1363 | id: 'test-workflow', 1364 | operations: [addIF, addHandler, connectWithBoth] 1365 | }; 1366 | 1367 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1368 | 1369 | expect(result.success).toBe(true); 1370 | 1371 | // Should use explicit sourceOutput ('false'), not smart branch parameter 1372 | // Note: explicit sourceOutput='false' creates connection on output named 'false' 1373 | // This is different from branch parameter which maps to sourceIndex 1374 | expect(result.workflow!.connections['IFOverride']['false']).toBeDefined(); 1375 | expect(result.workflow!.connections['IFOverride']['false'][0][0].node).toBe('OverrideHandler'); 1376 | expect(result.workflow!.connections['IFOverride']['main']).toBeUndefined(); 1377 | }); 1378 | 1379 | it('should not override explicit sourceIndex with case parameter', async () => { 1380 | const addSwitch: any = { 1381 | type: 'addNode', 1382 | node: { 1383 | name: 'Switch', 1384 | type: 'n8n-nodes-base.switch', 1385 | position: [400, 300] 1386 | } 1387 | }; 1388 | 1389 | const addHandler: any = { 1390 | type: 'addNode', 1391 | node: { 1392 | name: 'Handler', 1393 | type: 'n8n-nodes-base.set', 1394 | position: [600, 300] 1395 | } 1396 | }; 1397 | 1398 | // Both case and sourceIndex provided - sourceIndex should win 1399 | const connectWithBoth: any = { 1400 | type: 'addConnection', 1401 | source: 'Switch', 1402 | target: 'Handler', 1403 | case: 1, // Smart parameter suggests index 1 1404 | sourceIndex: 2 // Explicit parameter should override 1405 | }; 1406 | 1407 | const request: WorkflowDiffRequest = { 1408 | id: 'test-workflow', 1409 | operations: [addSwitch, addHandler, connectWithBoth] 1410 | }; 1411 | 1412 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1413 | 1414 | expect(result.success).toBe(true); 1415 | 1416 | // Should use explicit sourceIndex (2), not case (1) 1417 | expect(result.workflow!.connections['Switch']['main'][2]).toBeDefined(); 1418 | expect(result.workflow!.connections['Switch']['main'][2][0].node).toBe('Handler'); 1419 | expect(result.workflow!.connections['Switch']['main'][1]).toEqual([]); 1420 | }); 1421 | }); 1422 | 1423 | describe('AddConnection with sourceIndex (Phase 0 Fix)', () => { 1424 | it('should add connection to correct sourceIndex', async () => { 1425 | // Add IF node 1426 | const addNodeOp: AddNodeOperation = { 1427 | type: 'addNode', 1428 | node: { 1429 | name: 'IF', 1430 | type: 'n8n-nodes-base.if', 1431 | position: [600, 300] 1432 | } 1433 | }; 1434 | 1435 | // Add two different target nodes 1436 | const addNode1: AddNodeOperation = { 1437 | type: 'addNode', 1438 | node: { 1439 | name: 'SuccessHandler', 1440 | type: 'n8n-nodes-base.set', 1441 | position: [800, 200] 1442 | } 1443 | }; 1444 | 1445 | const addNode2: AddNodeOperation = { 1446 | type: 'addNode', 1447 | node: { 1448 | name: 'ErrorHandler', 1449 | type: 'n8n-nodes-base.set', 1450 | position: [800, 400] 1451 | } 1452 | }; 1453 | 1454 | // Connect to 'true' output at index 0 1455 | const addConnection1: AddConnectionOperation = { 1456 | type: 'addConnection', 1457 | source: 'IF', 1458 | target: 'SuccessHandler', 1459 | sourceOutput: 'true', 1460 | sourceIndex: 0 1461 | }; 1462 | 1463 | // Connect to 'false' output at index 0 1464 | const addConnection2: AddConnectionOperation = { 1465 | type: 'addConnection', 1466 | source: 'IF', 1467 | target: 'ErrorHandler', 1468 | sourceOutput: 'false', 1469 | sourceIndex: 0 1470 | }; 1471 | 1472 | const request: WorkflowDiffRequest = { 1473 | id: 'test-workflow', 1474 | operations: [addNodeOp, addNode1, addNode2, addConnection1, addConnection2] 1475 | }; 1476 | 1477 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1478 | 1479 | expect(result.success).toBe(true); 1480 | // Verify connections are at correct indices 1481 | expect(result.workflow!.connections['IF']['true']).toBeDefined(); 1482 | expect(result.workflow!.connections['IF']['true'][0]).toBeDefined(); 1483 | expect(result.workflow!.connections['IF']['true'][0][0].node).toBe('SuccessHandler'); 1484 | 1485 | expect(result.workflow!.connections['IF']['false']).toBeDefined(); 1486 | expect(result.workflow!.connections['IF']['false'][0]).toBeDefined(); 1487 | expect(result.workflow!.connections['IF']['false'][0][0].node).toBe('ErrorHandler'); 1488 | }); 1489 | 1490 | it('should support multiple connections at same sourceIndex (parallel execution)', async () => { 1491 | // Use a fresh workflow to avoid interference 1492 | const freshWorkflow = JSON.parse(JSON.stringify(baseWorkflow)); 1493 | 1494 | // Add three target nodes 1495 | const addNode1: AddNodeOperation = { 1496 | type: 'addNode', 1497 | node: { 1498 | name: 'Processor1', 1499 | type: 'n8n-nodes-base.set', 1500 | position: [600, 200] 1501 | } 1502 | }; 1503 | 1504 | const addNode2: AddNodeOperation = { 1505 | type: 'addNode', 1506 | node: { 1507 | name: 'Processor2', 1508 | type: 'n8n-nodes-base.set', 1509 | position: [600, 300] 1510 | } 1511 | }; 1512 | 1513 | const addNode3: AddNodeOperation = { 1514 | type: 'addNode', 1515 | node: { 1516 | name: 'Processor3', 1517 | type: 'n8n-nodes-base.set', 1518 | position: [600, 400] 1519 | } 1520 | }; 1521 | 1522 | // All connect from Webhook at sourceIndex 0 (parallel) 1523 | const addConnection1: AddConnectionOperation = { 1524 | type: 'addConnection', 1525 | source: 'Webhook', 1526 | target: 'Processor1', 1527 | sourceIndex: 0 1528 | }; 1529 | 1530 | const addConnection2: AddConnectionOperation = { 1531 | type: 'addConnection', 1532 | source: 'Webhook', 1533 | target: 'Processor2', 1534 | sourceIndex: 0 1535 | }; 1536 | 1537 | const addConnection3: AddConnectionOperation = { 1538 | type: 'addConnection', 1539 | source: 'Webhook', 1540 | target: 'Processor3', 1541 | sourceIndex: 0 1542 | }; 1543 | 1544 | const request: WorkflowDiffRequest = { 1545 | id: 'test-workflow', 1546 | operations: [addNode1, addNode2, addNode3, addConnection1, addConnection2, addConnection3] 1547 | }; 1548 | 1549 | const result = await diffEngine.applyDiff(freshWorkflow, request); 1550 | 1551 | expect(result.success).toBe(true); 1552 | // All three new processors plus the existing HTTP Request should be at index 0 1553 | // So we expect 4 total connections 1554 | const connectionsAtIndex0 = result.workflow!.connections['Webhook']['main'][0]; 1555 | expect(connectionsAtIndex0.length).toBeGreaterThanOrEqual(3); 1556 | const targets = connectionsAtIndex0.map((c: any) => c.node); 1557 | expect(targets).toContain('Processor1'); 1558 | expect(targets).toContain('Processor2'); 1559 | expect(targets).toContain('Processor3'); 1560 | }); 1561 | 1562 | it('should support connections at different sourceIndices (Switch node pattern)', async () => { 1563 | // Add Switch node 1564 | const addSwitchNode: AddNodeOperation = { 1565 | type: 'addNode', 1566 | node: { 1567 | name: 'Switch', 1568 | type: 'n8n-nodes-base.switch', 1569 | position: [400, 300] 1570 | } 1571 | }; 1572 | 1573 | // Add handlers for different cases 1574 | const addCase0: AddNodeOperation = { 1575 | type: 'addNode', 1576 | node: { 1577 | name: 'Case0Handler', 1578 | type: 'n8n-nodes-base.set', 1579 | position: [600, 200] 1580 | } 1581 | }; 1582 | 1583 | const addCase1: AddNodeOperation = { 1584 | type: 'addNode', 1585 | node: { 1586 | name: 'Case1Handler', 1587 | type: 'n8n-nodes-base.set', 1588 | position: [600, 300] 1589 | } 1590 | }; 1591 | 1592 | const addCase2: AddNodeOperation = { 1593 | type: 'addNode', 1594 | node: { 1595 | name: 'Case2Handler', 1596 | type: 'n8n-nodes-base.set', 1597 | position: [600, 400] 1598 | } 1599 | }; 1600 | 1601 | // Connect to different sourceIndices 1602 | const conn0: AddConnectionOperation = { 1603 | type: 'addConnection', 1604 | source: 'Switch', 1605 | target: 'Case0Handler', 1606 | sourceIndex: 0 1607 | }; 1608 | 1609 | const conn1: AddConnectionOperation = { 1610 | type: 'addConnection', 1611 | source: 'Switch', 1612 | target: 'Case1Handler', 1613 | sourceIndex: 1 1614 | }; 1615 | 1616 | const conn2: AddConnectionOperation = { 1617 | type: 'addConnection', 1618 | source: 'Switch', 1619 | target: 'Case2Handler', 1620 | sourceIndex: 2 1621 | }; 1622 | 1623 | const request: WorkflowDiffRequest = { 1624 | id: 'test-workflow', 1625 | operations: [addSwitchNode, addCase0, addCase1, addCase2, conn0, conn1, conn2] 1626 | }; 1627 | 1628 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1629 | 1630 | expect(result.success).toBe(true); 1631 | // Verify each case routes to correct handler 1632 | expect(result.workflow!.connections['Switch']['main'][0][0].node).toBe('Case0Handler'); 1633 | expect(result.workflow!.connections['Switch']['main'][1][0].node).toBe('Case1Handler'); 1634 | expect(result.workflow!.connections['Switch']['main'][2][0].node).toBe('Case2Handler'); 1635 | }); 1636 | 1637 | it('should properly handle sourceIndex 0 as explicit value vs default', async () => { 1638 | // Use a fresh workflow 1639 | const freshWorkflow = JSON.parse(JSON.stringify(baseWorkflow)); 1640 | 1641 | const addNode: AddNodeOperation = { 1642 | type: 'addNode', 1643 | node: { 1644 | name: 'TestNode', 1645 | type: 'n8n-nodes-base.set', 1646 | position: [600, 300] 1647 | } 1648 | }; 1649 | 1650 | // Explicit sourceIndex: 0 1651 | const connection1: AddConnectionOperation = { 1652 | type: 'addConnection', 1653 | source: 'Webhook', 1654 | target: 'TestNode', 1655 | sourceIndex: 0 1656 | }; 1657 | 1658 | const request: WorkflowDiffRequest = { 1659 | id: 'test-workflow', 1660 | operations: [addNode, connection1] 1661 | }; 1662 | 1663 | const result = await diffEngine.applyDiff(freshWorkflow, request); 1664 | 1665 | expect(result.success).toBe(true); 1666 | expect(result.workflow!.connections['Webhook']['main'][0]).toBeDefined(); 1667 | // TestNode should be in the connections (might not be first if HTTP Request already exists) 1668 | const targets = result.workflow!.connections['Webhook']['main'][0].map((c: any) => c.node); 1669 | expect(targets).toContain('TestNode'); 1670 | }); 1671 | }); 1672 | 1673 | describe('UpdateSettings Operation', () => { 1674 | it('should update workflow settings', async () => { 1675 | const operation: UpdateSettingsOperation = { 1676 | type: 'updateSettings', 1677 | settings: { 1678 | executionOrder: 'v0', 1679 | timezone: 'America/New_York', 1680 | executionTimeout: 300 1681 | } 1682 | }; 1683 | 1684 | const request: WorkflowDiffRequest = { 1685 | id: 'test-workflow', 1686 | operations: [operation] 1687 | }; 1688 | 1689 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1690 | 1691 | expect(result.success).toBe(true); 1692 | expect(result.workflow!.settings!.executionOrder).toBe('v0'); 1693 | expect(result.workflow!.settings!.timezone).toBe('America/New_York'); 1694 | expect(result.workflow!.settings!.executionTimeout).toBe(300); 1695 | }); 1696 | 1697 | it('should create settings object if not exists', async () => { 1698 | delete baseWorkflow.settings; 1699 | 1700 | const operation: UpdateSettingsOperation = { 1701 | type: 'updateSettings', 1702 | settings: { 1703 | saveManualExecutions: false 1704 | } 1705 | }; 1706 | 1707 | const request: WorkflowDiffRequest = { 1708 | id: 'test-workflow', 1709 | operations: [operation] 1710 | }; 1711 | 1712 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1713 | 1714 | expect(result.success).toBe(true); 1715 | expect(result.workflow!.settings).toBeDefined(); 1716 | expect(result.workflow!.settings!.saveManualExecutions).toBe(false); 1717 | }); 1718 | }); 1719 | 1720 | describe('UpdateName Operation', () => { 1721 | it('should update workflow name', async () => { 1722 | const operation: UpdateNameOperation = { 1723 | type: 'updateName', 1724 | name: 'Updated Workflow Name' 1725 | }; 1726 | 1727 | const request: WorkflowDiffRequest = { 1728 | id: 'test-workflow', 1729 | operations: [operation] 1730 | }; 1731 | 1732 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1733 | 1734 | expect(result.success).toBe(true); 1735 | expect(result.workflow!.name).toBe('Updated Workflow Name'); 1736 | }); 1737 | }); 1738 | 1739 | describe('Tag Operations', () => { 1740 | it('should add a new tag', async () => { 1741 | const operation: AddTagOperation = { 1742 | type: 'addTag', 1743 | tag: 'production' 1744 | }; 1745 | 1746 | const request: WorkflowDiffRequest = { 1747 | id: 'test-workflow', 1748 | operations: [operation] 1749 | }; 1750 | 1751 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1752 | 1753 | expect(result.success).toBe(true); 1754 | expect(result.workflow!.tags).toContain('production'); 1755 | expect(result.workflow!.tags).toHaveLength(3); 1756 | }); 1757 | 1758 | it('should not add duplicate tags', async () => { 1759 | const operation: AddTagOperation = { 1760 | type: 'addTag', 1761 | tag: 'test' // Already exists 1762 | }; 1763 | 1764 | const request: WorkflowDiffRequest = { 1765 | id: 'test-workflow', 1766 | operations: [operation] 1767 | }; 1768 | 1769 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1770 | 1771 | expect(result.success).toBe(true); 1772 | expect(result.workflow!.tags).toHaveLength(2); // No change 1773 | }); 1774 | 1775 | it('should create tags array if not exists', async () => { 1776 | delete baseWorkflow.tags; 1777 | 1778 | const operation: AddTagOperation = { 1779 | type: 'addTag', 1780 | tag: 'new-tag' 1781 | }; 1782 | 1783 | const request: WorkflowDiffRequest = { 1784 | id: 'test-workflow', 1785 | operations: [operation] 1786 | }; 1787 | 1788 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1789 | 1790 | expect(result.success).toBe(true); 1791 | expect(result.workflow!.tags).toBeDefined(); 1792 | expect(result.workflow!.tags).toEqual(['new-tag']); 1793 | }); 1794 | 1795 | it('should remove an existing tag', async () => { 1796 | const operation: RemoveTagOperation = { 1797 | type: 'removeTag', 1798 | tag: 'test' 1799 | }; 1800 | 1801 | const request: WorkflowDiffRequest = { 1802 | id: 'test-workflow', 1803 | operations: [operation] 1804 | }; 1805 | 1806 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1807 | 1808 | expect(result.success).toBe(true); 1809 | expect(result.workflow!.tags).not.toContain('test'); 1810 | expect(result.workflow!.tags).toHaveLength(1); 1811 | }); 1812 | 1813 | it('should handle removing non-existent tag gracefully', async () => { 1814 | const operation: RemoveTagOperation = { 1815 | type: 'removeTag', 1816 | tag: 'non-existent' 1817 | }; 1818 | 1819 | const request: WorkflowDiffRequest = { 1820 | id: 'test-workflow', 1821 | operations: [operation] 1822 | }; 1823 | 1824 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1825 | 1826 | expect(result.success).toBe(true); 1827 | expect(result.workflow!.tags).toHaveLength(2); // No change 1828 | }); 1829 | }); 1830 | 1831 | describe('ValidateOnly Mode', () => { 1832 | it('should validate without applying changes', async () => { 1833 | const operation: UpdateNameOperation = { 1834 | type: 'updateName', 1835 | name: 'Validated But Not Applied' 1836 | }; 1837 | 1838 | const request: WorkflowDiffRequest = { 1839 | id: 'test-workflow', 1840 | operations: [operation], 1841 | validateOnly: true 1842 | }; 1843 | 1844 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1845 | 1846 | expect(result.success).toBe(true); 1847 | expect(result.message).toContain('Validation successful'); 1848 | expect(result.workflow).toBeUndefined(); 1849 | }); 1850 | 1851 | it('should return validation errors in validateOnly mode', async () => { 1852 | const operation: RemoveNodeOperation = { 1853 | type: 'removeNode', 1854 | nodeId: 'non-existent' 1855 | }; 1856 | 1857 | const request: WorkflowDiffRequest = { 1858 | id: 'test-workflow', 1859 | operations: [operation], 1860 | validateOnly: true 1861 | }; 1862 | 1863 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1864 | 1865 | expect(result.success).toBe(false); 1866 | expect(result.errors![0].message).toContain('Node not found'); 1867 | }); 1868 | }); 1869 | 1870 | describe('Operation Ordering', () => { 1871 | it('should process node operations before connection operations', async () => { 1872 | // This tests the two-pass processing: nodes first, then connections 1873 | const operations = [ 1874 | { 1875 | type: 'addConnection', 1876 | source: 'NewNode', 1877 | target: 'slack-1' 1878 | } as AddConnectionOperation, 1879 | { 1880 | type: 'addNode', 1881 | node: { 1882 | name: 'NewNode', 1883 | type: 'n8n-nodes-base.code', 1884 | position: [800, 300] 1885 | } 1886 | } as AddNodeOperation 1887 | ]; 1888 | 1889 | const request: WorkflowDiffRequest = { 1890 | id: 'test-workflow', 1891 | operations 1892 | }; 1893 | 1894 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1895 | 1896 | expect(result.success).toBe(true); 1897 | expect(result.workflow!.nodes).toHaveLength(4); 1898 | expect(result.workflow!.connections['NewNode']).toBeDefined(); 1899 | }); 1900 | 1901 | it('should handle dependent operations correctly', async () => { 1902 | const operations = [ 1903 | { 1904 | type: 'removeNode', 1905 | nodeId: 'http-1' 1906 | } as RemoveNodeOperation, 1907 | { 1908 | type: 'addNode', 1909 | node: { 1910 | name: 'HTTP Request', // Reuse the same name 1911 | type: 'n8n-nodes-base.httpRequest', 1912 | position: [600, 300] 1913 | } 1914 | } as AddNodeOperation, 1915 | { 1916 | type: 'addConnection', 1917 | source: 'webhook-1', 1918 | target: 'HTTP Request' 1919 | } as AddConnectionOperation 1920 | ]; 1921 | 1922 | const request: WorkflowDiffRequest = { 1923 | id: 'test-workflow', 1924 | operations 1925 | }; 1926 | 1927 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1928 | 1929 | expect(result.success).toBe(true); 1930 | expect(result.workflow!.nodes).toHaveLength(3); 1931 | expect(result.workflow!.connections['Webhook'].main[0][0].node).toBe('HTTP Request'); 1932 | }); 1933 | }); 1934 | 1935 | describe('Error Handling', () => { 1936 | it('should handle unknown operation type', async () => { 1937 | const operation = { 1938 | type: 'unknownOperation', 1939 | someData: 'test' 1940 | } as any; 1941 | 1942 | const request: WorkflowDiffRequest = { 1943 | id: 'test-workflow', 1944 | operations: [operation] 1945 | }; 1946 | 1947 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1948 | 1949 | expect(result.success).toBe(false); 1950 | expect(result.errors![0].message).toContain('Unknown operation type'); 1951 | }); 1952 | 1953 | it('should stop on first validation error', async () => { 1954 | const operations = [ 1955 | { 1956 | type: 'removeNode', 1957 | nodeId: 'non-existent' 1958 | } as RemoveNodeOperation, 1959 | { 1960 | type: 'updateName', 1961 | name: 'This should not be applied' 1962 | } as UpdateNameOperation 1963 | ]; 1964 | 1965 | const request: WorkflowDiffRequest = { 1966 | id: 'test-workflow', 1967 | operations 1968 | }; 1969 | 1970 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1971 | 1972 | expect(result.success).toBe(false); 1973 | expect(result.errors).toHaveLength(1); 1974 | expect(result.errors![0].operation).toBe(0); 1975 | }); 1976 | 1977 | it('should return operation details in error', async () => { 1978 | const operation: RemoveNodeOperation = { 1979 | type: 'removeNode', 1980 | nodeId: 'non-existent', 1981 | description: 'Test remove operation' 1982 | }; 1983 | 1984 | const request: WorkflowDiffRequest = { 1985 | id: 'test-workflow', 1986 | operations: [operation] 1987 | }; 1988 | 1989 | const result = await diffEngine.applyDiff(baseWorkflow, request); 1990 | 1991 | expect(result.success).toBe(false); 1992 | expect(result.errors![0].details).toEqual(operation); 1993 | }); 1994 | }); 1995 | 1996 | describe('Complex Scenarios', () => { 1997 | it('should handle multiple operations of different types', async () => { 1998 | const operations = [ 1999 | { 2000 | type: 'updateName', 2001 | name: 'Complex Workflow' 2002 | } as UpdateNameOperation, 2003 | { 2004 | type: 'addNode', 2005 | node: { 2006 | name: 'Filter', 2007 | type: 'n8n-nodes-base.filter', 2008 | position: [800, 200] 2009 | } 2010 | } as AddNodeOperation, 2011 | { 2012 | type: 'removeConnection', 2013 | source: 'HTTP Request', // Use node name 2014 | target: 'Slack' // Use node name 2015 | } as RemoveConnectionOperation, 2016 | { 2017 | type: 'addConnection', 2018 | source: 'HTTP Request', // Use node name 2019 | target: 'Filter' 2020 | } as AddConnectionOperation, 2021 | { 2022 | type: 'addConnection', 2023 | source: 'Filter', 2024 | target: 'Slack' // Use node name 2025 | } as AddConnectionOperation 2026 | ]; 2027 | 2028 | const request: WorkflowDiffRequest = { 2029 | id: 'test-workflow', 2030 | operations 2031 | }; 2032 | 2033 | const result = await diffEngine.applyDiff(baseWorkflow, request); 2034 | 2035 | expect(result.success).toBe(true); 2036 | expect(result.workflow!.name).toBe('Complex Workflow'); 2037 | expect(result.workflow!.nodes).toHaveLength(4); 2038 | expect(result.workflow!.connections['HTTP Request'].main[0][0].node).toBe('Filter'); 2039 | expect(result.workflow!.connections['Filter'].main[0][0].node).toBe('Slack'); 2040 | expect(result.operationsApplied).toBe(5); 2041 | }); 2042 | 2043 | it('should preserve workflow immutability', async () => { 2044 | const originalNodes = [...baseWorkflow.nodes]; 2045 | const originalConnections = JSON.stringify(baseWorkflow.connections); 2046 | 2047 | const operation: UpdateNameOperation = { 2048 | type: 'updateName', 2049 | name: 'Modified' 2050 | }; 2051 | 2052 | const request: WorkflowDiffRequest = { 2053 | id: 'test-workflow', 2054 | operations: [operation] 2055 | }; 2056 | 2057 | await diffEngine.applyDiff(baseWorkflow, request); 2058 | 2059 | // Original workflow should remain unchanged 2060 | expect(baseWorkflow.name).toBe('Test Workflow'); 2061 | expect(baseWorkflow.nodes).toEqual(originalNodes); 2062 | expect(JSON.stringify(baseWorkflow.connections)).toBe(originalConnections); 2063 | }); 2064 | 2065 | it('should handle node ID as name fallback', async () => { 2066 | // Test the findNode helper's fallback behavior 2067 | const operation: UpdateNodeOperation = { 2068 | type: 'updateNode', 2069 | nodeId: 'Webhook', // Using name as ID 2070 | updates: { 2071 | 'parameters.path': 'new-webhook-path' 2072 | } 2073 | }; 2074 | 2075 | const request: WorkflowDiffRequest = { 2076 | id: 'test-workflow', 2077 | operations: [operation] 2078 | }; 2079 | 2080 | const result = await diffEngine.applyDiff(baseWorkflow, request); 2081 | 2082 | expect(result.success).toBe(true); 2083 | const updatedNode = result.workflow!.nodes.find((n: any) => n.name === 'Webhook'); 2084 | expect(updatedNode!.parameters.path).toBe('new-webhook-path'); 2085 | }); 2086 | }); 2087 | 2088 | describe('Success Messages', () => { 2089 | it('should provide informative success message', async () => { 2090 | const operations = [ 2091 | { 2092 | type: 'addNode', 2093 | node: { 2094 | name: 'Node1', 2095 | type: 'n8n-nodes-base.code', 2096 | position: [100, 100] 2097 | } 2098 | } as AddNodeOperation, 2099 | { 2100 | type: 'updateSettings', 2101 | settings: { timezone: 'UTC' } 2102 | } as UpdateSettingsOperation, 2103 | { 2104 | type: 'addTag', 2105 | tag: 'v2' 2106 | } as AddTagOperation 2107 | ]; 2108 | 2109 | const request: WorkflowDiffRequest = { 2110 | id: 'test-workflow', 2111 | operations 2112 | }; 2113 | 2114 | const result = await diffEngine.applyDiff(baseWorkflow, request); 2115 | 2116 | expect(result.success).toBe(true); 2117 | expect(result.message).toContain('Successfully applied 3 operations'); 2118 | expect(result.message).toContain('1 node ops'); 2119 | expect(result.message).toContain('2 other ops'); 2120 | }); 2121 | }); 2122 | 2123 | describe('New Features - v2.14.4', () => { 2124 | describe('cleanStaleConnections operation', () => { 2125 | it('should remove connections referencing non-existent nodes', async () => { 2126 | // Create a workflow with a stale connection 2127 | const workflow = builder.build() as Workflow; 2128 | 2129 | // Add a connection to a non-existent node manually 2130 | if (!workflow.connections['Webhook']) { 2131 | workflow.connections['Webhook'] = {}; 2132 | } 2133 | workflow.connections['Webhook']['main'] = [[ 2134 | { node: 'HTTP Request', type: 'main', index: 0 }, 2135 | { node: 'NonExistentNode', type: 'main', index: 0 } 2136 | ]]; 2137 | 2138 | const operations: CleanStaleConnectionsOperation[] = [{ 2139 | type: 'cleanStaleConnections' 2140 | }]; 2141 | 2142 | const request: WorkflowDiffRequest = { 2143 | id: 'test-workflow', 2144 | operations 2145 | }; 2146 | 2147 | const result = await diffEngine.applyDiff(workflow, request); 2148 | 2149 | expect(result.success).toBe(true); 2150 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(1); 2151 | expect(result.workflow.connections['Webhook']['main'][0][0].node).toBe('HTTP Request'); 2152 | }); 2153 | 2154 | it('should remove entire source connection if source node does not exist', async () => { 2155 | const workflow = builder.build() as Workflow; 2156 | 2157 | // Add connections from non-existent node 2158 | workflow.connections['GhostNode'] = { 2159 | 'main': [[ 2160 | { node: 'HTTP Request', type: 'main', index: 0 } 2161 | ]] 2162 | }; 2163 | 2164 | const operations: CleanStaleConnectionsOperation[] = [{ 2165 | type: 'cleanStaleConnections' 2166 | }]; 2167 | 2168 | const request: WorkflowDiffRequest = { 2169 | id: 'test-workflow', 2170 | operations 2171 | }; 2172 | 2173 | const result = await diffEngine.applyDiff(workflow, request); 2174 | 2175 | expect(result.success).toBe(true); 2176 | expect(result.workflow.connections['GhostNode']).toBeUndefined(); 2177 | }); 2178 | 2179 | it('should support dryRun mode', async () => { 2180 | const workflow = builder.build() as Workflow; 2181 | 2182 | // Add a stale connection 2183 | if (!workflow.connections['Webhook']) { 2184 | workflow.connections['Webhook'] = {}; 2185 | } 2186 | workflow.connections['Webhook']['main'] = [[ 2187 | { node: 'HTTP Request', type: 'main', index: 0 }, 2188 | { node: 'NonExistentNode', type: 'main', index: 0 } 2189 | ]]; 2190 | 2191 | const operations: CleanStaleConnectionsOperation[] = [{ 2192 | type: 'cleanStaleConnections', 2193 | dryRun: true 2194 | }]; 2195 | 2196 | const request: WorkflowDiffRequest = { 2197 | id: 'test-workflow', 2198 | operations 2199 | }; 2200 | 2201 | const result = await diffEngine.applyDiff(workflow, request); 2202 | 2203 | expect(result.success).toBe(true); 2204 | // In dryRun, stale connection should still be present (not actually removed) 2205 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(2); 2206 | }); 2207 | }); 2208 | 2209 | describe('replaceConnections operation', () => { 2210 | it('should replace entire connections object', async () => { 2211 | const workflow = builder.build() as Workflow; 2212 | 2213 | const newConnections = { 2214 | 'Webhook': { 2215 | 'main': [[ 2216 | { node: 'Slack', type: 'main', index: 0 } 2217 | ]] 2218 | } 2219 | }; 2220 | 2221 | const operations: ReplaceConnectionsOperation[] = [{ 2222 | type: 'replaceConnections', 2223 | connections: newConnections 2224 | }]; 2225 | 2226 | const request: WorkflowDiffRequest = { 2227 | id: 'test-workflow', 2228 | operations 2229 | }; 2230 | 2231 | const result = await diffEngine.applyDiff(workflow, request); 2232 | 2233 | expect(result.success).toBe(true); 2234 | expect(result.workflow.connections).toEqual(newConnections); 2235 | expect(result.workflow.connections['HTTP Request']).toBeUndefined(); 2236 | }); 2237 | 2238 | it('should fail if referenced nodes do not exist', async () => { 2239 | const workflow = builder.build() as Workflow; 2240 | 2241 | const newConnections = { 2242 | 'Webhook': { 2243 | 'main': [[ 2244 | { node: 'NonExistentNode', type: 'main', index: 0 } 2245 | ]] 2246 | } 2247 | }; 2248 | 2249 | const operations: ReplaceConnectionsOperation[] = [{ 2250 | type: 'replaceConnections', 2251 | connections: newConnections 2252 | }]; 2253 | 2254 | const request: WorkflowDiffRequest = { 2255 | id: 'test-workflow', 2256 | operations 2257 | }; 2258 | 2259 | const result = await diffEngine.applyDiff(workflow, request); 2260 | 2261 | expect(result.success).toBe(false); 2262 | expect(result.errors).toBeDefined(); 2263 | expect(result.errors![0].message).toContain('Target node not found'); 2264 | }); 2265 | }); 2266 | 2267 | describe('removeConnection with ignoreErrors flag', () => { 2268 | it('should succeed when connection does not exist if ignoreErrors is true', async () => { 2269 | const workflow = builder.build() as Workflow; 2270 | 2271 | const operations: RemoveConnectionOperation[] = [{ 2272 | type: 'removeConnection', 2273 | source: 'Webhook', 2274 | target: 'NonExistentNode', 2275 | ignoreErrors: true 2276 | }]; 2277 | 2278 | const request: WorkflowDiffRequest = { 2279 | id: 'test-workflow', 2280 | operations 2281 | }; 2282 | 2283 | const result = await diffEngine.applyDiff(workflow, request); 2284 | 2285 | expect(result.success).toBe(true); 2286 | }); 2287 | 2288 | it('should fail when connection does not exist if ignoreErrors is false', async () => { 2289 | const workflow = builder.build() as Workflow; 2290 | 2291 | const operations: RemoveConnectionOperation[] = [{ 2292 | type: 'removeConnection', 2293 | source: 'Webhook', 2294 | target: 'NonExistentNode', 2295 | ignoreErrors: false 2296 | }]; 2297 | 2298 | const request: WorkflowDiffRequest = { 2299 | id: 'test-workflow', 2300 | operations 2301 | }; 2302 | 2303 | const result = await diffEngine.applyDiff(workflow, request); 2304 | 2305 | expect(result.success).toBe(false); 2306 | expect(result.errors).toBeDefined(); 2307 | }); 2308 | 2309 | it('should default to atomic behavior when ignoreErrors is not specified', async () => { 2310 | const workflow = builder.build() as Workflow; 2311 | 2312 | const operations: RemoveConnectionOperation[] = [{ 2313 | type: 'removeConnection', 2314 | source: 'Webhook', 2315 | target: 'NonExistentNode' 2316 | }]; 2317 | 2318 | const request: WorkflowDiffRequest = { 2319 | id: 'test-workflow', 2320 | operations 2321 | }; 2322 | 2323 | const result = await diffEngine.applyDiff(workflow, request); 2324 | 2325 | expect(result.success).toBe(false); 2326 | expect(result.errors).toBeDefined(); 2327 | }); 2328 | }); 2329 | 2330 | describe('continueOnError mode', () => { 2331 | it('should apply valid operations and report failed ones', async () => { 2332 | const workflow = builder.build() as Workflow; 2333 | 2334 | const operations: WorkflowDiffOperation[] = [ 2335 | { 2336 | type: 'updateName', 2337 | name: 'New Workflow Name' 2338 | } as UpdateNameOperation, 2339 | { 2340 | type: 'removeConnection', 2341 | source: 'Webhook', 2342 | target: 'NonExistentNode' 2343 | } as RemoveConnectionOperation, 2344 | { 2345 | type: 'addTag', 2346 | tag: 'production' 2347 | } as AddTagOperation 2348 | ]; 2349 | 2350 | const request: WorkflowDiffRequest = { 2351 | id: 'test-workflow', 2352 | operations, 2353 | continueOnError: true 2354 | }; 2355 | 2356 | const result = await diffEngine.applyDiff(workflow, request); 2357 | 2358 | expect(result.success).toBe(true); 2359 | expect(result.applied).toEqual([0, 2]); // Operations 0 and 2 succeeded 2360 | expect(result.failed).toEqual([1]); // Operation 1 failed 2361 | expect(result.errors).toHaveLength(1); 2362 | expect(result.workflow.name).toBe('New Workflow Name'); 2363 | expect(result.workflow.tags).toContain('production'); 2364 | }); 2365 | 2366 | it('should return success false if all operations fail in continueOnError mode', async () => { 2367 | const workflow = builder.build() as Workflow; 2368 | 2369 | const operations: WorkflowDiffOperation[] = [ 2370 | { 2371 | type: 'removeConnection', 2372 | source: 'Webhook', 2373 | target: 'Node1' 2374 | } as RemoveConnectionOperation, 2375 | { 2376 | type: 'removeConnection', 2377 | source: 'Webhook', 2378 | target: 'Node2' 2379 | } as RemoveConnectionOperation 2380 | ]; 2381 | 2382 | const request: WorkflowDiffRequest = { 2383 | id: 'test-workflow', 2384 | operations, 2385 | continueOnError: true 2386 | }; 2387 | 2388 | const result = await diffEngine.applyDiff(workflow, request); 2389 | 2390 | expect(result.success).toBe(false); 2391 | expect(result.applied).toHaveLength(0); 2392 | expect(result.failed).toEqual([0, 1]); 2393 | }); 2394 | 2395 | it('should use atomic mode by default when continueOnError is not specified', async () => { 2396 | const workflow = builder.build() as Workflow; 2397 | 2398 | const operations: WorkflowDiffOperation[] = [ 2399 | { 2400 | type: 'updateName', 2401 | name: 'New Name' 2402 | } as UpdateNameOperation, 2403 | { 2404 | type: 'removeConnection', 2405 | source: 'Webhook', 2406 | target: 'NonExistent' 2407 | } as RemoveConnectionOperation 2408 | ]; 2409 | 2410 | const request: WorkflowDiffRequest = { 2411 | id: 'test-workflow', 2412 | operations 2413 | }; 2414 | 2415 | const result = await diffEngine.applyDiff(workflow, request); 2416 | 2417 | expect(result.success).toBe(false); 2418 | expect(result.applied).toBeUndefined(); 2419 | expect(result.failed).toBeUndefined(); 2420 | // Name should not have been updated due to atomic behavior 2421 | expect(result.workflow).toBeUndefined(); 2422 | }); 2423 | }); 2424 | 2425 | describe('Backwards compatibility', () => { 2426 | it('should maintain existing behavior for all previous operation types', async () => { 2427 | const workflow = builder.build() as Workflow; 2428 | 2429 | const operations: WorkflowDiffOperation[] = [ 2430 | { type: 'updateName', name: 'Test' } as UpdateNameOperation, 2431 | { type: 'addTag', tag: 'test' } as AddTagOperation, 2432 | { type: 'removeTag', tag: 'automation' } as RemoveTagOperation, 2433 | { type: 'updateSettings', settings: { timezone: 'UTC' } } as UpdateSettingsOperation 2434 | ]; 2435 | 2436 | const request: WorkflowDiffRequest = { 2437 | id: 'test-workflow', 2438 | operations 2439 | }; 2440 | 2441 | const result = await diffEngine.applyDiff(workflow, request); 2442 | 2443 | expect(result.success).toBe(true); 2444 | expect(result.operationsApplied).toBe(4); 2445 | }); 2446 | }); 2447 | }); 2448 | 2449 | describe('v2.14.4 Coverage Improvements', () => { 2450 | describe('cleanStaleConnections - Advanced Scenarios', () => { 2451 | it('should clean up multiple stale connections across different output types', async () => { 2452 | const workflow = builder.build() as Workflow; 2453 | 2454 | // Add an IF node with multiple outputs 2455 | workflow.nodes.push({ 2456 | id: 'if-1', 2457 | name: 'IF', 2458 | type: 'n8n-nodes-base.if', 2459 | typeVersion: 1, 2460 | position: [600, 400], 2461 | parameters: {} 2462 | }); 2463 | 2464 | // Add connections with both valid and stale targets on different outputs 2465 | workflow.connections['IF'] = { 2466 | 'true': [[ 2467 | { node: 'Slack', type: 'main', index: 0 }, 2468 | { node: 'StaleNode1', type: 'main', index: 0 } 2469 | ]], 2470 | 'false': [[ 2471 | { node: 'HTTP Request', type: 'main', index: 0 }, 2472 | { node: 'StaleNode2', type: 'main', index: 0 } 2473 | ]] 2474 | }; 2475 | 2476 | const operations: CleanStaleConnectionsOperation[] = [{ 2477 | type: 'cleanStaleConnections' 2478 | }]; 2479 | 2480 | const request: WorkflowDiffRequest = { 2481 | id: 'test-workflow', 2482 | operations 2483 | }; 2484 | 2485 | const result = await diffEngine.applyDiff(workflow, request); 2486 | 2487 | expect(result.success).toBe(true); 2488 | expect(result.workflow.connections['IF']['true'][0]).toHaveLength(1); 2489 | expect(result.workflow.connections['IF']['true'][0][0].node).toBe('Slack'); 2490 | expect(result.workflow.connections['IF']['false'][0]).toHaveLength(1); 2491 | expect(result.workflow.connections['IF']['false'][0][0].node).toBe('HTTP Request'); 2492 | }); 2493 | 2494 | it('should remove empty output types after cleaning stale connections', async () => { 2495 | const workflow = builder.build() as Workflow; 2496 | 2497 | // Add node with connections 2498 | workflow.nodes.push({ 2499 | id: 'if-1', 2500 | name: 'IF', 2501 | type: 'n8n-nodes-base.if', 2502 | typeVersion: 1, 2503 | position: [600, 400], 2504 | parameters: {} 2505 | }); 2506 | 2507 | // Add connections where all targets in one output are stale 2508 | workflow.connections['IF'] = { 2509 | 'true': [[ 2510 | { node: 'StaleNode1', type: 'main', index: 0 }, 2511 | { node: 'StaleNode2', type: 'main', index: 0 } 2512 | ]], 2513 | 'false': [[ 2514 | { node: 'Slack', type: 'main', index: 0 } 2515 | ]] 2516 | }; 2517 | 2518 | const operations: CleanStaleConnectionsOperation[] = [{ 2519 | type: 'cleanStaleConnections' 2520 | }]; 2521 | 2522 | const request: WorkflowDiffRequest = { 2523 | id: 'test-workflow', 2524 | operations 2525 | }; 2526 | 2527 | const result = await diffEngine.applyDiff(workflow, request); 2528 | 2529 | expect(result.success).toBe(true); 2530 | expect(result.workflow.connections['IF']['true']).toBeUndefined(); 2531 | expect(result.workflow.connections['IF']['false']).toBeDefined(); 2532 | expect(result.workflow.connections['IF']['false'][0][0].node).toBe('Slack'); 2533 | }); 2534 | 2535 | it('should clean up entire node connections when all outputs become empty', async () => { 2536 | const workflow = builder.build() as Workflow; 2537 | 2538 | // Add node 2539 | workflow.nodes.push({ 2540 | id: 'if-1', 2541 | name: 'IF', 2542 | type: 'n8n-nodes-base.if', 2543 | typeVersion: 1, 2544 | position: [600, 400], 2545 | parameters: {} 2546 | }); 2547 | 2548 | // Add connections where ALL targets are stale 2549 | workflow.connections['IF'] = { 2550 | 'true': [[ 2551 | { node: 'StaleNode1', type: 'main', index: 0 } 2552 | ]], 2553 | 'false': [[ 2554 | { node: 'StaleNode2', type: 'main', index: 0 } 2555 | ]] 2556 | }; 2557 | 2558 | const operations: CleanStaleConnectionsOperation[] = [{ 2559 | type: 'cleanStaleConnections' 2560 | }]; 2561 | 2562 | const request: WorkflowDiffRequest = { 2563 | id: 'test-workflow', 2564 | operations 2565 | }; 2566 | 2567 | const result = await diffEngine.applyDiff(workflow, request); 2568 | 2569 | expect(result.success).toBe(true); 2570 | expect(result.workflow.connections['IF']).toBeUndefined(); 2571 | }); 2572 | 2573 | it('should handle dryRun with multiple stale connections', async () => { 2574 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 2575 | 2576 | // Add stale connections from both valid and invalid source nodes 2577 | workflow.connections['GhostNode'] = { 2578 | 'main': [[{ node: 'HTTP Request', type: 'main', index: 0 }]] 2579 | }; 2580 | 2581 | if (!workflow.connections['Webhook']) { 2582 | workflow.connections['Webhook'] = {}; 2583 | } 2584 | workflow.connections['Webhook']['main'] = [[ 2585 | { node: 'HTTP Request', type: 'main', index: 0 }, 2586 | { node: 'StaleNode1', type: 'main', index: 0 }, 2587 | { node: 'StaleNode2', type: 'main', index: 0 } 2588 | ]]; 2589 | 2590 | const originalConnections = JSON.parse(JSON.stringify(workflow.connections)); 2591 | 2592 | const operations: CleanStaleConnectionsOperation[] = [{ 2593 | type: 'cleanStaleConnections', 2594 | dryRun: true 2595 | }]; 2596 | 2597 | const request: WorkflowDiffRequest = { 2598 | id: 'test-workflow', 2599 | operations 2600 | }; 2601 | 2602 | const result = await diffEngine.applyDiff(workflow, request); 2603 | 2604 | expect(result.success).toBe(true); 2605 | // Connections should remain unchanged in dryRun 2606 | expect(JSON.stringify(result.workflow.connections)).toBe(JSON.stringify(originalConnections)); 2607 | }); 2608 | 2609 | it('should handle workflow with no stale connections', async () => { 2610 | // Use baseWorkflow which has name-based connections 2611 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 2612 | const originalConnectionsCount = Object.keys(workflow.connections).length; 2613 | 2614 | const operations: CleanStaleConnectionsOperation[] = [{ 2615 | type: 'cleanStaleConnections' 2616 | }]; 2617 | 2618 | const request: WorkflowDiffRequest = { 2619 | id: 'test-workflow', 2620 | operations 2621 | }; 2622 | 2623 | const result = await diffEngine.applyDiff(workflow, request); 2624 | 2625 | expect(result.success).toBe(true); 2626 | // Connections should remain unchanged (no stale connections to remove) 2627 | // Verify by checking connection count 2628 | expect(Object.keys(result.workflow.connections).length).toBe(originalConnectionsCount); 2629 | expect(result.workflow.connections['Webhook']).toBeDefined(); 2630 | expect(result.workflow.connections['HTTP Request']).toBeDefined(); 2631 | }); 2632 | }); 2633 | 2634 | describe('replaceConnections - Advanced Scenarios', () => { 2635 | it('should fail validation when source node does not exist', async () => { 2636 | const workflow = builder.build() as Workflow; 2637 | 2638 | const newConnections = { 2639 | 'NonExistentSource': { 2640 | 'main': [[ 2641 | { node: 'Slack', type: 'main', index: 0 } 2642 | ]] 2643 | } 2644 | }; 2645 | 2646 | const operations: ReplaceConnectionsOperation[] = [{ 2647 | type: 'replaceConnections', 2648 | connections: newConnections 2649 | }]; 2650 | 2651 | const request: WorkflowDiffRequest = { 2652 | id: 'test-workflow', 2653 | operations 2654 | }; 2655 | 2656 | const result = await diffEngine.applyDiff(workflow, request); 2657 | 2658 | expect(result.success).toBe(false); 2659 | expect(result.errors).toBeDefined(); 2660 | expect(result.errors![0].message).toContain('Source node not found'); 2661 | }); 2662 | 2663 | it('should successfully replace with empty connections object', async () => { 2664 | const workflow = builder.build() as Workflow; 2665 | 2666 | const operations: ReplaceConnectionsOperation[] = [{ 2667 | type: 'replaceConnections', 2668 | connections: {} 2669 | }]; 2670 | 2671 | const request: WorkflowDiffRequest = { 2672 | id: 'test-workflow', 2673 | operations 2674 | }; 2675 | 2676 | const result = await diffEngine.applyDiff(workflow, request); 2677 | 2678 | expect(result.success).toBe(true); 2679 | expect(result.workflow.connections).toEqual({}); 2680 | }); 2681 | 2682 | it('should handle complex connection structures with multiple outputs', async () => { 2683 | const workflow = builder.build() as Workflow; 2684 | 2685 | // Add IF node 2686 | workflow.nodes.push({ 2687 | id: 'if-1', 2688 | name: 'IF', 2689 | type: 'n8n-nodes-base.if', 2690 | typeVersion: 1, 2691 | position: [600, 400], 2692 | parameters: {} 2693 | }); 2694 | 2695 | const newConnections = { 2696 | 'Webhook': { 2697 | 'main': [[ 2698 | { node: 'IF', type: 'main', index: 0 } 2699 | ]] 2700 | }, 2701 | 'IF': { 2702 | 'true': [[ 2703 | { node: 'Slack', type: 'main', index: 0 } 2704 | ]], 2705 | 'false': [[ 2706 | { node: 'HTTP Request', type: 'main', index: 0 } 2707 | ]] 2708 | } 2709 | }; 2710 | 2711 | const operations: ReplaceConnectionsOperation[] = [{ 2712 | type: 'replaceConnections', 2713 | connections: newConnections 2714 | }]; 2715 | 2716 | const request: WorkflowDiffRequest = { 2717 | id: 'test-workflow', 2718 | operations 2719 | }; 2720 | 2721 | const result = await diffEngine.applyDiff(workflow, request); 2722 | 2723 | expect(result.success).toBe(true); 2724 | expect(result.workflow.connections).toEqual(newConnections); 2725 | }); 2726 | }); 2727 | 2728 | describe('removeConnection with ignoreErrors - Advanced Scenarios', () => { 2729 | it('should succeed when source node does not exist with ignoreErrors', async () => { 2730 | const workflow = builder.build() as Workflow; 2731 | 2732 | const operations: RemoveConnectionOperation[] = [{ 2733 | type: 'removeConnection', 2734 | source: 'NonExistentSource', 2735 | target: 'Slack', 2736 | ignoreErrors: true 2737 | }]; 2738 | 2739 | const request: WorkflowDiffRequest = { 2740 | id: 'test-workflow', 2741 | operations 2742 | }; 2743 | 2744 | const result = await diffEngine.applyDiff(workflow, request); 2745 | 2746 | expect(result.success).toBe(true); 2747 | // Workflow should remain unchanged (verify by checking node count) 2748 | expect(Object.keys(result.workflow.connections).length).toBe(Object.keys(baseWorkflow.connections).length); 2749 | }); 2750 | 2751 | it('should succeed when both source and target nodes do not exist with ignoreErrors', async () => { 2752 | const workflow = builder.build() as Workflow; 2753 | 2754 | const operations: RemoveConnectionOperation[] = [{ 2755 | type: 'removeConnection', 2756 | source: 'NonExistentSource', 2757 | target: 'NonExistentTarget', 2758 | ignoreErrors: true 2759 | }]; 2760 | 2761 | const request: WorkflowDiffRequest = { 2762 | id: 'test-workflow', 2763 | operations 2764 | }; 2765 | 2766 | const result = await diffEngine.applyDiff(workflow, request); 2767 | 2768 | expect(result.success).toBe(true); 2769 | }); 2770 | 2771 | it('should succeed when connection exists but target node does not with ignoreErrors', async () => { 2772 | const workflow = builder.build() as Workflow; 2773 | 2774 | // This is an edge case where connection references a valid node but we're trying to remove to non-existent 2775 | const operations: RemoveConnectionOperation[] = [{ 2776 | type: 'removeConnection', 2777 | source: 'Webhook', 2778 | target: 'NonExistentTarget', 2779 | ignoreErrors: true 2780 | }]; 2781 | 2782 | const request: WorkflowDiffRequest = { 2783 | id: 'test-workflow', 2784 | operations 2785 | }; 2786 | 2787 | const result = await diffEngine.applyDiff(workflow, request); 2788 | 2789 | expect(result.success).toBe(true); 2790 | }); 2791 | 2792 | it('should fail when source node does not exist without ignoreErrors', async () => { 2793 | const workflow = builder.build() as Workflow; 2794 | 2795 | const operations: RemoveConnectionOperation[] = [{ 2796 | type: 'removeConnection', 2797 | source: 'NonExistentSource', 2798 | target: 'Slack', 2799 | ignoreErrors: false 2800 | }]; 2801 | 2802 | const request: WorkflowDiffRequest = { 2803 | id: 'test-workflow', 2804 | operations 2805 | }; 2806 | 2807 | const result = await diffEngine.applyDiff(workflow, request); 2808 | 2809 | expect(result.success).toBe(false); 2810 | expect(result.errors![0].message).toContain('Source node not found'); 2811 | }); 2812 | }); 2813 | 2814 | describe('continueOnError - Advanced Scenarios', () => { 2815 | it('should catch runtime errors during operation application', async () => { 2816 | const workflow = builder.build() as Workflow; 2817 | 2818 | // Create an operation that will pass validation but fail during application 2819 | // This is simulated by causing an error in the apply phase 2820 | const operations: WorkflowDiffOperation[] = [ 2821 | { 2822 | type: 'updateName', 2823 | name: 'Valid Operation' 2824 | } as UpdateNameOperation, 2825 | { 2826 | type: 'updateNode', 2827 | nodeId: 'webhook-1', 2828 | updates: { 2829 | // This will pass validation but could fail in complex scenarios 2830 | 'parameters.invalidDeepPath.nested.value': 'test' 2831 | } 2832 | } as UpdateNodeOperation, 2833 | { 2834 | type: 'addTag', 2835 | tag: 'another-valid' 2836 | } as AddTagOperation 2837 | ]; 2838 | 2839 | const request: WorkflowDiffRequest = { 2840 | id: 'test-workflow', 2841 | operations, 2842 | continueOnError: true 2843 | }; 2844 | 2845 | const result = await diffEngine.applyDiff(workflow, request); 2846 | 2847 | // All operations should succeed in this case (no runtime errors expected) 2848 | expect(result.success).toBe(true); 2849 | expect(result.applied).toBeDefined(); 2850 | expect(result.applied!.length).toBeGreaterThan(0); 2851 | }); 2852 | 2853 | it('should handle mixed validation and runtime errors', async () => { 2854 | const workflow = builder.build() as Workflow; 2855 | 2856 | const operations: WorkflowDiffOperation[] = [ 2857 | { 2858 | type: 'updateName', 2859 | name: 'Operation 0' 2860 | } as UpdateNameOperation, 2861 | { 2862 | type: 'removeNode', 2863 | nodeId: 'non-existent-1' 2864 | } as RemoveNodeOperation, 2865 | { 2866 | type: 'addTag', 2867 | tag: 'tag1' 2868 | } as AddTagOperation, 2869 | { 2870 | type: 'removeConnection', 2871 | source: 'Webhook', 2872 | target: 'NonExistent' 2873 | } as RemoveConnectionOperation, 2874 | { 2875 | type: 'addTag', 2876 | tag: 'tag2' 2877 | } as AddTagOperation 2878 | ]; 2879 | 2880 | const request: WorkflowDiffRequest = { 2881 | id: 'test-workflow', 2882 | operations, 2883 | continueOnError: true 2884 | }; 2885 | 2886 | const result = await diffEngine.applyDiff(workflow, request); 2887 | 2888 | expect(result.success).toBe(true); 2889 | expect(result.applied).toContain(0); // updateName 2890 | expect(result.applied).toContain(2); // first addTag 2891 | expect(result.applied).toContain(4); // second addTag 2892 | expect(result.failed).toContain(1); // removeNode 2893 | expect(result.failed).toContain(3); // removeConnection 2894 | expect(result.errors).toHaveLength(2); 2895 | }); 2896 | 2897 | it('should support validateOnly with continueOnError mode', async () => { 2898 | const workflow = builder.build() as Workflow; 2899 | 2900 | const operations: WorkflowDiffOperation[] = [ 2901 | { 2902 | type: 'updateName', 2903 | name: 'New Name' 2904 | } as UpdateNameOperation, 2905 | { 2906 | type: 'removeNode', 2907 | nodeId: 'non-existent' 2908 | } as RemoveNodeOperation, 2909 | { 2910 | type: 'addTag', 2911 | tag: 'test-tag' 2912 | } as AddTagOperation 2913 | ]; 2914 | 2915 | const request: WorkflowDiffRequest = { 2916 | id: 'test-workflow', 2917 | operations, 2918 | continueOnError: true, 2919 | validateOnly: true 2920 | }; 2921 | 2922 | const result = await diffEngine.applyDiff(workflow, request); 2923 | 2924 | expect(result.workflow).toBeUndefined(); 2925 | expect(result.message).toContain('Validation completed'); 2926 | expect(result.applied).toEqual([0, 2]); 2927 | expect(result.failed).toEqual([1]); 2928 | expect(result.errors).toHaveLength(1); 2929 | }); 2930 | 2931 | it('should handle all operations failing with helpful message', async () => { 2932 | const workflow = builder.build() as Workflow; 2933 | 2934 | const operations: WorkflowDiffOperation[] = [ 2935 | { 2936 | type: 'removeNode', 2937 | nodeId: 'non-existent-1' 2938 | } as RemoveNodeOperation, 2939 | { 2940 | type: 'removeNode', 2941 | nodeId: 'non-existent-2' 2942 | } as RemoveNodeOperation, 2943 | { 2944 | type: 'removeConnection', 2945 | source: 'Invalid', 2946 | target: 'Invalid' 2947 | } as RemoveConnectionOperation 2948 | ]; 2949 | 2950 | const request: WorkflowDiffRequest = { 2951 | id: 'test-workflow', 2952 | operations, 2953 | continueOnError: true 2954 | }; 2955 | 2956 | const result = await diffEngine.applyDiff(workflow, request); 2957 | 2958 | expect(result.success).toBe(false); 2959 | expect(result.applied).toHaveLength(0); 2960 | expect(result.failed).toEqual([0, 1, 2]); 2961 | expect(result.errors).toHaveLength(3); 2962 | expect(result.message).toContain('0 operations'); 2963 | expect(result.message).toContain('3 failed'); 2964 | }); 2965 | 2966 | it('should preserve operation order in applied and failed arrays', async () => { 2967 | const workflow = builder.build() as Workflow; 2968 | 2969 | const operations: WorkflowDiffOperation[] = [ 2970 | { type: 'updateName', name: 'Name1' } as UpdateNameOperation, // 0 - success 2971 | { type: 'removeNode', nodeId: 'invalid1' } as RemoveNodeOperation, // 1 - fail 2972 | { type: 'addTag', tag: 'tag1' } as AddTagOperation, // 2 - success 2973 | { type: 'removeNode', nodeId: 'invalid2' } as RemoveNodeOperation, // 3 - fail 2974 | { type: 'addTag', tag: 'tag2' } as AddTagOperation, // 4 - success 2975 | { type: 'removeNode', nodeId: 'invalid3' } as RemoveNodeOperation, // 5 - fail 2976 | ]; 2977 | 2978 | const request: WorkflowDiffRequest = { 2979 | id: 'test-workflow', 2980 | operations, 2981 | continueOnError: true 2982 | }; 2983 | 2984 | const result = await diffEngine.applyDiff(workflow, request); 2985 | 2986 | expect(result.success).toBe(true); 2987 | expect(result.applied).toEqual([0, 2, 4]); 2988 | expect(result.failed).toEqual([1, 3, 5]); 2989 | }); 2990 | }); 2991 | 2992 | describe('Edge Cases and Error Paths', () => { 2993 | it('should handle workflow with initialized but empty connections', async () => { 2994 | const workflow = builder.build() as Workflow; 2995 | // Start with empty connections 2996 | workflow.connections = {}; 2997 | 2998 | // Add some nodes but no connections 2999 | workflow.nodes.push({ 3000 | id: 'orphan-1', 3001 | name: 'Orphan Node', 3002 | type: 'n8n-nodes-base.code', 3003 | typeVersion: 1, 3004 | position: [800, 400], 3005 | parameters: {} 3006 | }); 3007 | 3008 | const operations: CleanStaleConnectionsOperation[] = [{ 3009 | type: 'cleanStaleConnections' 3010 | }]; 3011 | 3012 | const request: WorkflowDiffRequest = { 3013 | id: 'test-workflow', 3014 | operations 3015 | }; 3016 | 3017 | const result = await diffEngine.applyDiff(workflow, request); 3018 | 3019 | expect(result.success).toBe(true); 3020 | expect(result.workflow.connections).toEqual({}); 3021 | }); 3022 | 3023 | it('should handle empty connections in cleanStaleConnections', async () => { 3024 | const workflow = builder.build() as Workflow; 3025 | workflow.connections = {}; 3026 | 3027 | const operations: CleanStaleConnectionsOperation[] = [{ 3028 | type: 'cleanStaleConnections' 3029 | }]; 3030 | 3031 | const request: WorkflowDiffRequest = { 3032 | id: 'test-workflow', 3033 | operations 3034 | }; 3035 | 3036 | const result = await diffEngine.applyDiff(workflow, request); 3037 | 3038 | expect(result.success).toBe(true); 3039 | expect(result.workflow.connections).toEqual({}); 3040 | }); 3041 | 3042 | it('should handle removeConnection with ignoreErrors on valid but non-connected nodes', async () => { 3043 | const workflow = builder.build() as Workflow; 3044 | 3045 | // Both nodes exist but no connection between them 3046 | const operations: RemoveConnectionOperation[] = [{ 3047 | type: 'removeConnection', 3048 | source: 'Slack', 3049 | target: 'Webhook', 3050 | ignoreErrors: true 3051 | }]; 3052 | 3053 | const request: WorkflowDiffRequest = { 3054 | id: 'test-workflow', 3055 | operations 3056 | }; 3057 | 3058 | const result = await diffEngine.applyDiff(workflow, request); 3059 | 3060 | expect(result.success).toBe(true); 3061 | }); 3062 | 3063 | it('should handle replaceConnections with nested connection arrays', async () => { 3064 | const workflow = builder.build() as Workflow; 3065 | 3066 | const newConnections = { 3067 | 'Webhook': { 3068 | 'main': [ 3069 | [ 3070 | { node: 'HTTP Request', type: 'main', index: 0 }, 3071 | { node: 'Slack', type: 'main', index: 0 } 3072 | ], 3073 | [ 3074 | { node: 'HTTP Request', type: 'main', index: 1 } 3075 | ] 3076 | ] 3077 | } 3078 | }; 3079 | 3080 | const operations: ReplaceConnectionsOperation[] = [{ 3081 | type: 'replaceConnections', 3082 | connections: newConnections 3083 | }]; 3084 | 3085 | const request: WorkflowDiffRequest = { 3086 | id: 'test-workflow', 3087 | operations 3088 | }; 3089 | 3090 | const result = await diffEngine.applyDiff(workflow, request); 3091 | 3092 | expect(result.success).toBe(true); 3093 | expect(result.workflow.connections['Webhook']['main']).toHaveLength(2); 3094 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(2); 3095 | expect(result.workflow.connections['Webhook']['main'][1]).toHaveLength(1); 3096 | }); 3097 | 3098 | it('should validate cleanStaleConnections always returns null', async () => { 3099 | const workflow = builder.build() as Workflow; 3100 | 3101 | // This tests that validation for cleanStaleConnections always passes 3102 | const operations: CleanStaleConnectionsOperation[] = [{ 3103 | type: 'cleanStaleConnections' 3104 | }]; 3105 | 3106 | const request: WorkflowDiffRequest = { 3107 | id: 'test-workflow', 3108 | operations, 3109 | validateOnly: true 3110 | }; 3111 | 3112 | const result = await diffEngine.applyDiff(workflow, request); 3113 | 3114 | expect(result.success).toBe(true); 3115 | expect(result.message).toContain('Validation successful'); 3116 | }); 3117 | 3118 | it('should handle continueOnError with no operations', async () => { 3119 | const workflow = builder.build() as Workflow; 3120 | 3121 | const request: WorkflowDiffRequest = { 3122 | id: 'test-workflow', 3123 | operations: [], 3124 | continueOnError: true 3125 | }; 3126 | 3127 | const result = await diffEngine.applyDiff(workflow, request); 3128 | 3129 | expect(result.success).toBe(false); 3130 | expect(result.applied).toEqual([]); 3131 | expect(result.failed).toEqual([]); 3132 | }); 3133 | }); 3134 | 3135 | describe('Integration Tests - v2.14.4 Features Combined', () => { 3136 | it('should combine cleanStaleConnections and replaceConnections', async () => { 3137 | const workflow = builder.build() as Workflow; 3138 | 3139 | // Add stale connections 3140 | workflow.connections['GhostNode'] = { 3141 | 'main': [[{ node: 'Slack', type: 'main', index: 0 }]] 3142 | }; 3143 | 3144 | const operations: WorkflowDiffOperation[] = [ 3145 | { 3146 | type: 'cleanStaleConnections' 3147 | } as CleanStaleConnectionsOperation, 3148 | { 3149 | type: 'replaceConnections', 3150 | connections: { 3151 | 'Webhook': { 3152 | 'main': [[{ node: 'Slack', type: 'main', index: 0 }]] 3153 | } 3154 | } 3155 | } as ReplaceConnectionsOperation 3156 | ]; 3157 | 3158 | const request: WorkflowDiffRequest = { 3159 | id: 'test-workflow', 3160 | operations 3161 | }; 3162 | 3163 | const result = await diffEngine.applyDiff(workflow, request); 3164 | 3165 | expect(result.success).toBe(true); 3166 | expect(result.workflow.connections['GhostNode']).toBeUndefined(); 3167 | expect(result.workflow.connections['Webhook']['main'][0][0].node).toBe('Slack'); 3168 | }); 3169 | 3170 | it('should use continueOnError with new v2.14.4 operations', async () => { 3171 | const workflow = builder.build() as Workflow; 3172 | 3173 | const operations: WorkflowDiffOperation[] = [ 3174 | { 3175 | type: 'cleanStaleConnections' 3176 | } as CleanStaleConnectionsOperation, 3177 | { 3178 | type: 'replaceConnections', 3179 | connections: { 3180 | 'NonExistentNode': { 3181 | 'main': [[{ node: 'Slack', type: 'main', index: 0 }]] 3182 | } 3183 | } 3184 | } as ReplaceConnectionsOperation, 3185 | { 3186 | type: 'removeConnection', 3187 | source: 'Webhook', 3188 | target: 'NonExistent', 3189 | ignoreErrors: true 3190 | } as RemoveConnectionOperation, 3191 | { 3192 | type: 'addTag', 3193 | tag: 'final-tag' 3194 | } as AddTagOperation 3195 | ]; 3196 | 3197 | const request: WorkflowDiffRequest = { 3198 | id: 'test-workflow', 3199 | operations, 3200 | continueOnError: true 3201 | }; 3202 | 3203 | const result = await diffEngine.applyDiff(workflow, request); 3204 | 3205 | expect(result.success).toBe(true); 3206 | expect(result.applied).toContain(0); // cleanStaleConnections 3207 | expect(result.failed).toContain(1); // replaceConnections with invalid node 3208 | expect(result.applied).toContain(2); // removeConnection with ignoreErrors 3209 | expect(result.applied).toContain(3); // addTag 3210 | expect(result.workflow.tags).toContain('final-tag'); 3211 | }); 3212 | }); 3213 | 3214 | describe('Additional Edge Cases for 90% Coverage', () => { 3215 | it('should handle cleanStaleConnections with connections from valid node to itself', async () => { 3216 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3217 | 3218 | // Add self-referencing connection 3219 | if (!workflow.connections['Webhook']) { 3220 | workflow.connections['Webhook'] = {}; 3221 | } 3222 | workflow.connections['Webhook']['main'] = [[ 3223 | { node: 'Webhook', type: 'main', index: 0 }, 3224 | { node: 'HTTP Request', type: 'main', index: 0 } 3225 | ]]; 3226 | 3227 | const operations: CleanStaleConnectionsOperation[] = [{ 3228 | type: 'cleanStaleConnections' 3229 | }]; 3230 | 3231 | const request: WorkflowDiffRequest = { 3232 | id: 'test-workflow', 3233 | operations 3234 | }; 3235 | 3236 | const result = await diffEngine.applyDiff(workflow, request); 3237 | 3238 | expect(result.success).toBe(true); 3239 | // Self-referencing connection should remain (it's valid) 3240 | expect(result.workflow.connections['Webhook']['main'][0].some((c: any) => c.node === 'Webhook')).toBe(true); 3241 | }); 3242 | 3243 | it('should handle removeTag when tags array does not exist', async () => { 3244 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3245 | delete workflow.tags; 3246 | 3247 | const operations: RemoveTagOperation[] = [{ 3248 | type: 'removeTag', 3249 | tag: 'non-existent' 3250 | }]; 3251 | 3252 | const request: WorkflowDiffRequest = { 3253 | id: 'test-workflow', 3254 | operations 3255 | }; 3256 | 3257 | const result = await diffEngine.applyDiff(workflow, request); 3258 | 3259 | expect(result.success).toBe(true); 3260 | }); 3261 | 3262 | it('should handle cleanStaleConnections with multiple connection indices', async () => { 3263 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3264 | 3265 | // Add connections with multiple indices 3266 | workflow.connections['Webhook'] = { 3267 | 'main': [ 3268 | [ 3269 | { node: 'HTTP Request', type: 'main', index: 0 }, 3270 | { node: 'Slack', type: 'main', index: 0 } 3271 | ], 3272 | [ 3273 | { node: 'StaleNode', type: 'main', index: 0 } 3274 | ] 3275 | ] 3276 | }; 3277 | 3278 | const operations: CleanStaleConnectionsOperation[] = [{ 3279 | type: 'cleanStaleConnections' 3280 | }]; 3281 | 3282 | const request: WorkflowDiffRequest = { 3283 | id: 'test-workflow', 3284 | operations 3285 | }; 3286 | 3287 | const result = await diffEngine.applyDiff(workflow, request); 3288 | 3289 | expect(result.success).toBe(true); 3290 | // First index should remain with both valid connections 3291 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(2); 3292 | // Second index with stale node should be removed, so only one index remains 3293 | expect(result.workflow.connections['Webhook']['main'].length).toBe(1); 3294 | }); 3295 | 3296 | it('should handle continueOnError with runtime error during apply', async () => { 3297 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3298 | 3299 | // Create a scenario that might cause runtime errors 3300 | const operations: WorkflowDiffOperation[] = [ 3301 | { 3302 | type: 'updateNode', 3303 | nodeId: 'webhook-1', 3304 | updates: { 3305 | 'parameters.test': 'value1' 3306 | } 3307 | } as UpdateNodeOperation, 3308 | { 3309 | type: 'removeNode', 3310 | nodeId: 'invalid-node' 3311 | } as RemoveNodeOperation, 3312 | { 3313 | type: 'updateNode', 3314 | nodeName: 'HTTP Request', 3315 | updates: { 3316 | 'parameters.test': 'value2' 3317 | } 3318 | } as UpdateNodeOperation 3319 | ]; 3320 | 3321 | const request: WorkflowDiffRequest = { 3322 | id: 'test-workflow', 3323 | operations, 3324 | continueOnError: true 3325 | }; 3326 | 3327 | const result = await diffEngine.applyDiff(workflow, request); 3328 | 3329 | expect(result.applied).toContain(0); 3330 | expect(result.failed).toContain(1); 3331 | expect(result.applied).toContain(2); 3332 | }); 3333 | 3334 | it('should handle atomic mode failure in node operations', async () => { 3335 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3336 | 3337 | const operations: WorkflowDiffOperation[] = [ 3338 | { 3339 | type: 'updateNode', 3340 | nodeId: 'webhook-1', 3341 | updates: { 3342 | 'parameters.valid': 'update' 3343 | } 3344 | } as UpdateNodeOperation, 3345 | { 3346 | type: 'removeNode', 3347 | nodeId: 'invalid-node' 3348 | } as RemoveNodeOperation 3349 | ]; 3350 | 3351 | const request: WorkflowDiffRequest = { 3352 | id: 'test-workflow', 3353 | operations 3354 | }; 3355 | 3356 | const result = await diffEngine.applyDiff(workflow, request); 3357 | 3358 | expect(result.success).toBe(false); 3359 | expect(result.errors).toHaveLength(1); 3360 | expect(result.errors![0].operation).toBe(1); 3361 | }); 3362 | 3363 | it('should handle atomic mode failure in connection operations', async () => { 3364 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3365 | 3366 | const operations: WorkflowDiffOperation[] = [ 3367 | { 3368 | type: 'addNode', 3369 | node: { 3370 | name: 'NewNode', 3371 | type: 'n8n-nodes-base.code', 3372 | position: [900, 300], 3373 | parameters: {} 3374 | } 3375 | } as AddNodeOperation, 3376 | { 3377 | type: 'addConnection', 3378 | source: 'NewNode', 3379 | target: 'InvalidTarget' 3380 | } as AddConnectionOperation 3381 | ]; 3382 | 3383 | const request: WorkflowDiffRequest = { 3384 | id: 'test-workflow', 3385 | operations 3386 | }; 3387 | 3388 | const result = await diffEngine.applyDiff(workflow, request); 3389 | 3390 | expect(result.success).toBe(false); 3391 | expect(result.errors).toHaveLength(1); 3392 | expect(result.errors![0].operation).toBe(1); 3393 | }); 3394 | 3395 | it('should handle cleanStaleConnections in dryRun with source node missing', async () => { 3396 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3397 | 3398 | // Add connections from non-existent source 3399 | workflow.connections['GhostSource1'] = { 3400 | 'main': [[{ node: 'Slack', type: 'main', index: 0 }]] 3401 | }; 3402 | 3403 | workflow.connections['GhostSource2'] = { 3404 | 'main': [[{ node: 'HTTP Request', type: 'main', index: 0 }]], 3405 | 'error': [[{ node: 'Slack', type: 'main', index: 0 }]] 3406 | }; 3407 | 3408 | const operations: CleanStaleConnectionsOperation[] = [{ 3409 | type: 'cleanStaleConnections', 3410 | dryRun: true 3411 | }]; 3412 | 3413 | const request: WorkflowDiffRequest = { 3414 | id: 'test-workflow', 3415 | operations 3416 | }; 3417 | 3418 | const result = await diffEngine.applyDiff(workflow, request); 3419 | 3420 | expect(result.success).toBe(true); 3421 | // In dryRun, connections should remain 3422 | expect(result.workflow.connections['GhostSource1']).toBeDefined(); 3423 | expect(result.workflow.connections['GhostSource2']).toBeDefined(); 3424 | }); 3425 | 3426 | it('should handle validateOnly in atomic mode', async () => { 3427 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3428 | 3429 | const operations: WorkflowDiffOperation[] = [ 3430 | { 3431 | type: 'updateName', 3432 | name: 'Validated Name' 3433 | } as UpdateNameOperation, 3434 | { 3435 | type: 'addNode', 3436 | node: { 3437 | name: 'ValidNode', 3438 | type: 'n8n-nodes-base.code', 3439 | position: [900, 300], 3440 | parameters: {} 3441 | } 3442 | } as AddNodeOperation 3443 | ]; 3444 | 3445 | const request: WorkflowDiffRequest = { 3446 | id: 'test-workflow', 3447 | operations, 3448 | validateOnly: true 3449 | }; 3450 | 3451 | const result = await diffEngine.applyDiff(workflow, request); 3452 | 3453 | expect(result.success).toBe(true); 3454 | expect(result.workflow).toBeUndefined(); 3455 | expect(result.message).toContain('Validation successful'); 3456 | expect(result.message).toContain('not applied'); 3457 | }); 3458 | 3459 | it('should handle malformed workflow object gracefully', async () => { 3460 | // Create a malformed workflow that will cause JSON parsing errors 3461 | const malformedWorkflow: any = { 3462 | name: 'Test', 3463 | nodes: [], 3464 | connections: {} 3465 | }; 3466 | 3467 | // Create circular reference to cause JSON.stringify to fail 3468 | malformedWorkflow.self = malformedWorkflow; 3469 | 3470 | const operations: WorkflowDiffOperation[] = [{ 3471 | type: 'updateName', 3472 | name: 'New Name' 3473 | } as UpdateNameOperation]; 3474 | 3475 | const request: WorkflowDiffRequest = { 3476 | id: 'test-workflow', 3477 | operations 3478 | }; 3479 | 3480 | const result = await diffEngine.applyDiff(malformedWorkflow, request); 3481 | 3482 | // Should handle the error gracefully 3483 | expect(result.success).toBe(false); 3484 | expect(result.errors).toBeDefined(); 3485 | }); 3486 | 3487 | it('should handle continueOnError with all operations causing errors', async () => { 3488 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3489 | 3490 | const operations: WorkflowDiffOperation[] = [ 3491 | { 3492 | type: 'removeNode', 3493 | nodeId: 'invalid1' 3494 | } as RemoveNodeOperation, 3495 | { 3496 | type: 'removeNode', 3497 | nodeId: 'invalid2' 3498 | } as RemoveNodeOperation, 3499 | { 3500 | type: 'addConnection', 3501 | source: 'Invalid1', 3502 | target: 'Invalid2' 3503 | } as AddConnectionOperation 3504 | ]; 3505 | 3506 | const request: WorkflowDiffRequest = { 3507 | id: 'test-workflow', 3508 | operations, 3509 | continueOnError: true 3510 | }; 3511 | 3512 | const result = await diffEngine.applyDiff(workflow, request); 3513 | 3514 | expect(result.success).toBe(false); 3515 | expect(result.applied).toEqual([]); 3516 | expect(result.failed).toEqual([0, 1, 2]); 3517 | expect(result.errors).toHaveLength(3); 3518 | }); 3519 | 3520 | it('should handle atomic mode with empty operations array', async () => { 3521 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3522 | 3523 | const request: WorkflowDiffRequest = { 3524 | id: 'test-workflow', 3525 | operations: [] 3526 | }; 3527 | 3528 | const result = await diffEngine.applyDiff(workflow, request); 3529 | 3530 | expect(result.success).toBe(true); 3531 | expect(result.operationsApplied).toBe(0); 3532 | }); 3533 | 3534 | it('should handle removeConnection without sourceOutput specified', async () => { 3535 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3536 | 3537 | const operations: RemoveConnectionOperation[] = [{ 3538 | type: 'removeConnection', 3539 | source: 'Webhook', 3540 | target: 'HTTP Request' 3541 | // sourceOutput not specified, should default to 'main' 3542 | }]; 3543 | 3544 | const request: WorkflowDiffRequest = { 3545 | id: 'test-workflow', 3546 | operations 3547 | }; 3548 | 3549 | const result = await diffEngine.applyDiff(workflow, request); 3550 | 3551 | expect(result.success).toBe(true); 3552 | }); 3553 | 3554 | it('should handle continueOnError validateOnly with all errors', async () => { 3555 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3556 | 3557 | const operations: WorkflowDiffOperation[] = [ 3558 | { 3559 | type: 'removeNode', 3560 | nodeId: 'invalid1' 3561 | } as RemoveNodeOperation, 3562 | { 3563 | type: 'removeNode', 3564 | nodeId: 'invalid2' 3565 | } as RemoveNodeOperation 3566 | ]; 3567 | 3568 | const request: WorkflowDiffRequest = { 3569 | id: 'test-workflow', 3570 | operations, 3571 | continueOnError: true, 3572 | validateOnly: true 3573 | }; 3574 | 3575 | const result = await diffEngine.applyDiff(workflow, request); 3576 | 3577 | expect(result.success).toBe(false); 3578 | expect(result.message).toContain('Validation completed'); 3579 | expect(result.errors).toHaveLength(2); 3580 | expect(result.workflow).toBeUndefined(); 3581 | }); 3582 | 3583 | 3584 | it('should handle addConnection with all optional parameters specified', async () => { 3585 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3586 | 3587 | // Add Code node 3588 | workflow.nodes.push({ 3589 | id: 'code-1', 3590 | name: 'Code', 3591 | type: 'n8n-nodes-base.code', 3592 | typeVersion: 1, 3593 | position: [900, 300], 3594 | parameters: {} 3595 | }); 3596 | 3597 | const operations: AddConnectionOperation[] = [{ 3598 | type: 'addConnection', 3599 | source: 'Slack', 3600 | target: 'Code', 3601 | sourceOutput: 'main', 3602 | targetInput: 'main', 3603 | sourceIndex: 0, 3604 | targetIndex: 0 3605 | }]; 3606 | 3607 | const request: WorkflowDiffRequest = { 3608 | id: 'test-workflow', 3609 | operations 3610 | }; 3611 | 3612 | const result = await diffEngine.applyDiff(workflow, request); 3613 | 3614 | expect(result.success).toBe(true); 3615 | expect(result.workflow.connections['Slack']['main'][0][0].node).toBe('Code'); 3616 | expect(result.workflow.connections['Slack']['main'][0][0].type).toBe('main'); 3617 | expect(result.workflow.connections['Slack']['main'][0][0].index).toBe(0); 3618 | }); 3619 | 3620 | it('should handle cleanStaleConnections actually removing source node connections', async () => { 3621 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3622 | 3623 | // Add connections from non-existent source that should be deleted entirely 3624 | workflow.connections['NonExistentSource1'] = { 3625 | 'main': [[ 3626 | { node: 'Slack', type: 'main', index: 0 } 3627 | ]] 3628 | }; 3629 | 3630 | workflow.connections['NonExistentSource2'] = { 3631 | 'main': [[ 3632 | { node: 'HTTP Request', type: 'main', index: 0 } 3633 | ]], 3634 | 'error': [[ 3635 | { node: 'Slack', type: 'main', index: 0 } 3636 | ]] 3637 | }; 3638 | 3639 | const operations: CleanStaleConnectionsOperation[] = [{ 3640 | type: 'cleanStaleConnections' 3641 | }]; 3642 | 3643 | const request: WorkflowDiffRequest = { 3644 | id: 'test-workflow', 3645 | operations 3646 | }; 3647 | 3648 | const result = await diffEngine.applyDiff(workflow, request); 3649 | 3650 | expect(result.success).toBe(true); 3651 | expect(result.workflow.connections['NonExistentSource1']).toBeUndefined(); 3652 | expect(result.workflow.connections['NonExistentSource2']).toBeUndefined(); 3653 | }); 3654 | 3655 | it('should handle validateOnly with no errors in continueOnError mode', async () => { 3656 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3657 | 3658 | const operations: WorkflowDiffOperation[] = [ 3659 | { 3660 | type: 'updateName', 3661 | name: 'Valid Name' 3662 | } as UpdateNameOperation, 3663 | { 3664 | type: 'addTag', 3665 | tag: 'valid-tag' 3666 | } as AddTagOperation 3667 | ]; 3668 | 3669 | const request: WorkflowDiffRequest = { 3670 | id: 'test-workflow', 3671 | operations, 3672 | continueOnError: true, 3673 | validateOnly: true 3674 | }; 3675 | 3676 | const result = await diffEngine.applyDiff(workflow, request); 3677 | 3678 | expect(result.success).toBe(true); 3679 | expect(result.message).toContain('Validation successful'); 3680 | expect(result.errors).toBeUndefined(); 3681 | expect(result.applied).toEqual([0, 1]); 3682 | expect(result.failed).toEqual([]); 3683 | }); 3684 | 3685 | it('should handle addConnection initializing missing connection structure', async () => { 3686 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3687 | 3688 | // Add node without any connections 3689 | workflow.nodes.push({ 3690 | id: 'orphan-1', 3691 | name: 'Orphan', 3692 | type: 'n8n-nodes-base.code', 3693 | typeVersion: 1, 3694 | position: [900, 300], 3695 | parameters: {} 3696 | }); 3697 | 3698 | // Ensure Orphan has no connections initially 3699 | delete workflow.connections['Orphan']; 3700 | 3701 | const operations: AddConnectionOperation[] = [{ 3702 | type: 'addConnection', 3703 | source: 'Orphan', 3704 | target: 'Slack' 3705 | }]; 3706 | 3707 | const request: WorkflowDiffRequest = { 3708 | id: 'test-workflow', 3709 | operations 3710 | }; 3711 | 3712 | const result = await diffEngine.applyDiff(workflow, request); 3713 | 3714 | expect(result.success).toBe(true); 3715 | expect(result.workflow.connections['Orphan']).toBeDefined(); 3716 | expect(result.workflow.connections['Orphan']['main']).toBeDefined(); 3717 | expect(result.workflow.connections['Orphan']['main'][0][0].node).toBe('Slack'); 3718 | }); 3719 | 3720 | it('should handle addConnection with sourceIndex requiring array expansion', async () => { 3721 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3722 | 3723 | // Add Code node 3724 | workflow.nodes.push({ 3725 | id: 'code-1', 3726 | name: 'Code', 3727 | type: 'n8n-nodes-base.code', 3728 | typeVersion: 1, 3729 | position: [900, 300], 3730 | parameters: {} 3731 | }); 3732 | 3733 | const operations: AddConnectionOperation[] = [{ 3734 | type: 'addConnection', 3735 | source: 'Slack', 3736 | target: 'Code', 3737 | sourceIndex: 5 // Force array expansion to index 5 3738 | }]; 3739 | 3740 | const request: WorkflowDiffRequest = { 3741 | id: 'test-workflow', 3742 | operations 3743 | }; 3744 | 3745 | const result = await diffEngine.applyDiff(workflow, request); 3746 | 3747 | expect(result.success).toBe(true); 3748 | expect(result.workflow.connections['Slack']['main'].length).toBeGreaterThanOrEqual(6); 3749 | expect(result.workflow.connections['Slack']['main'][5][0].node).toBe('Code'); 3750 | }); 3751 | 3752 | it('should handle removeConnection cleaning up empty output structures', async () => { 3753 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3754 | 3755 | // Set up a connection that will leave empty structures after removal 3756 | workflow.connections['HTTP Request'] = { 3757 | 'main': [[ 3758 | { node: 'Slack', type: 'main', index: 0 } 3759 | ]] 3760 | }; 3761 | 3762 | const operations: RemoveConnectionOperation[] = [{ 3763 | type: 'removeConnection', 3764 | source: 'HTTP Request', 3765 | target: 'Slack' 3766 | }]; 3767 | 3768 | const request: WorkflowDiffRequest = { 3769 | id: 'test-workflow', 3770 | operations 3771 | }; 3772 | 3773 | const result = await diffEngine.applyDiff(workflow, request); 3774 | 3775 | expect(result.success).toBe(true); 3776 | // Connection should be removed entirely (cleanup of empty structures) 3777 | expect(result.workflow.connections['HTTP Request']).toBeUndefined(); 3778 | }); 3779 | 3780 | it('should handle complex cleanStaleConnections scenario with mixed valid/invalid', async () => { 3781 | const workflow = JSON.parse(JSON.stringify(baseWorkflow)); 3782 | 3783 | // Create a complex scenario with multiple source nodes 3784 | workflow.connections['Webhook'] = { 3785 | 'main': [[ 3786 | { node: 'HTTP Request', type: 'main', index: 0 }, 3787 | { node: 'Stale1', type: 'main', index: 0 }, 3788 | { node: 'Slack', type: 'main', index: 0 }, 3789 | { node: 'Stale2', type: 'main', index: 0 } 3790 | ]], 3791 | 'error': [[ 3792 | { node: 'Stale3', type: 'main', index: 0 } 3793 | ]] 3794 | }; 3795 | 3796 | const operations: CleanStaleConnectionsOperation[] = [{ 3797 | type: 'cleanStaleConnections' 3798 | }]; 3799 | 3800 | const request: WorkflowDiffRequest = { 3801 | id: 'test-workflow', 3802 | operations 3803 | }; 3804 | 3805 | const result = await diffEngine.applyDiff(workflow, request); 3806 | 3807 | expect(result.success).toBe(true); 3808 | // Only valid connections should remain 3809 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(2); 3810 | expect(result.workflow.connections['Webhook']['main'][0].some((c: any) => c.node === 'HTTP Request')).toBe(true); 3811 | expect(result.workflow.connections['Webhook']['main'][0].some((c: any) => c.node === 'Slack')).toBe(true); 3812 | // Error output should be removed entirely (all stale) 3813 | expect(result.workflow.connections['Webhook']['error']).toBeUndefined(); 3814 | }); 3815 | }); 3816 | }); 3817 | 3818 | // Issue #270: Special characters in node names 3819 | describe('Special Characters in Node Names', () => { 3820 | it('should handle apostrophes in node names for addConnection', async () => { 3821 | // Default n8n Manual Trigger node name contains apostrophes 3822 | const workflowWithApostrophes = { 3823 | ...baseWorkflow, 3824 | nodes: [ 3825 | ...baseWorkflow.nodes, 3826 | { 3827 | id: 'manual-trigger-1', 3828 | name: "When clicking 'Execute workflow'", // Contains apostrophes 3829 | type: 'n8n-nodes-base.manualTrigger', 3830 | typeVersion: 1, 3831 | position: [100, 100] as [number, number], 3832 | parameters: {} 3833 | } 3834 | ] 3835 | }; 3836 | 3837 | const operation: AddConnectionOperation = { 3838 | type: 'addConnection', 3839 | source: "When clicking 'Execute workflow'", // Using node name with apostrophes 3840 | target: 'HTTP Request' 3841 | }; 3842 | 3843 | const request: WorkflowDiffRequest = { 3844 | id: 'test-workflow', 3845 | operations: [operation] 3846 | }; 3847 | 3848 | const result = await diffEngine.applyDiff(workflowWithApostrophes as Workflow, request); 3849 | 3850 | expect(result.success).toBe(true); 3851 | expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined(); 3852 | expect(result.workflow.connections["When clicking 'Execute workflow'"].main).toBeDefined(); 3853 | }); 3854 | 3855 | it('should handle double quotes in node names', async () => { 3856 | const workflowWithQuotes = { 3857 | ...baseWorkflow, 3858 | nodes: [ 3859 | ...baseWorkflow.nodes, 3860 | { 3861 | id: 'quoted-node-1', 3862 | name: 'Node with "quotes"', // Contains double quotes 3863 | type: 'n8n-nodes-base.set', 3864 | typeVersion: 1, 3865 | position: [100, 100] as [number, number], 3866 | parameters: {} 3867 | } 3868 | ] 3869 | }; 3870 | 3871 | const operation: AddConnectionOperation = { 3872 | type: 'addConnection', 3873 | source: 'Node with "quotes"', 3874 | target: 'HTTP Request' 3875 | }; 3876 | 3877 | const request: WorkflowDiffRequest = { 3878 | id: 'test-workflow', 3879 | operations: [operation] 3880 | }; 3881 | 3882 | const result = await diffEngine.applyDiff(workflowWithQuotes as Workflow, request); 3883 | 3884 | expect(result.success).toBe(true); 3885 | expect(result.workflow.connections['Node with "quotes"']).toBeDefined(); 3886 | }); 3887 | 3888 | it('should handle backslashes in node names', async () => { 3889 | const workflowWithBackslashes = { 3890 | ...baseWorkflow, 3891 | nodes: [ 3892 | ...baseWorkflow.nodes, 3893 | { 3894 | id: 'backslash-node-1', 3895 | name: 'Path\\with\\backslashes', // Contains backslashes 3896 | type: 'n8n-nodes-base.set', 3897 | typeVersion: 1, 3898 | position: [100, 100] as [number, number], 3899 | parameters: {} 3900 | } 3901 | ] 3902 | }; 3903 | 3904 | const operation: AddConnectionOperation = { 3905 | type: 'addConnection', 3906 | source: 'Path\\with\\backslashes', 3907 | target: 'HTTP Request' 3908 | }; 3909 | 3910 | const request: WorkflowDiffRequest = { 3911 | id: 'test-workflow', 3912 | operations: [operation] 3913 | }; 3914 | 3915 | const result = await diffEngine.applyDiff(workflowWithBackslashes as Workflow, request); 3916 | 3917 | expect(result.success).toBe(true); 3918 | expect(result.workflow.connections['Path\\with\\backslashes']).toBeDefined(); 3919 | }); 3920 | 3921 | it('should handle mixed special characters in node names', async () => { 3922 | const workflowWithMixed = { 3923 | ...baseWorkflow, 3924 | nodes: [ 3925 | ...baseWorkflow.nodes, 3926 | { 3927 | id: 'complex-node-1', 3928 | name: "Complex 'name' with \"quotes\" and \\backslash", 3929 | type: 'n8n-nodes-base.set', 3930 | typeVersion: 1, 3931 | position: [100, 100] as [number, number], 3932 | parameters: {} 3933 | } 3934 | ] 3935 | }; 3936 | 3937 | const operation: AddConnectionOperation = { 3938 | type: 'addConnection', 3939 | source: "Complex 'name' with \"quotes\" and \\backslash", 3940 | target: 'HTTP Request' 3941 | }; 3942 | 3943 | const request: WorkflowDiffRequest = { 3944 | id: 'test-workflow', 3945 | operations: [operation] 3946 | }; 3947 | 3948 | const result = await diffEngine.applyDiff(workflowWithMixed as Workflow, request); 3949 | 3950 | expect(result.success).toBe(true); 3951 | expect(result.workflow.connections["Complex 'name' with \"quotes\" and \\backslash"]).toBeDefined(); 3952 | }); 3953 | 3954 | it('should handle special characters in removeConnection', async () => { 3955 | const workflowWithConnections = { 3956 | ...baseWorkflow, 3957 | nodes: [ 3958 | ...baseWorkflow.nodes, 3959 | { 3960 | id: 'apostrophe-node-1', 3961 | name: "Node with 'apostrophes'", 3962 | type: 'n8n-nodes-base.set', 3963 | typeVersion: 1, 3964 | position: [100, 100] as [number, number], 3965 | parameters: {} 3966 | } 3967 | ], 3968 | connections: { 3969 | ...baseWorkflow.connections, 3970 | "Node with 'apostrophes'": { 3971 | main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] 3972 | } 3973 | } 3974 | }; 3975 | 3976 | const operation: RemoveConnectionOperation = { 3977 | type: 'removeConnection', 3978 | source: "Node with 'apostrophes'", 3979 | target: 'HTTP Request' 3980 | }; 3981 | 3982 | const request: WorkflowDiffRequest = { 3983 | id: 'test-workflow', 3984 | operations: [operation] 3985 | }; 3986 | 3987 | const result = await diffEngine.applyDiff(workflowWithConnections as any, request); 3988 | 3989 | expect(result.success).toBe(true); 3990 | expect(result.workflow.connections["Node with 'apostrophes'"]).toBeUndefined(); 3991 | }); 3992 | 3993 | it('should handle special characters in updateNode', async () => { 3994 | const workflowWithSpecialNode = { 3995 | ...baseWorkflow, 3996 | nodes: [ 3997 | ...baseWorkflow.nodes, 3998 | { 3999 | id: 'special-node-1', 4000 | name: "Update 'this' node", 4001 | type: 'n8n-nodes-base.set', 4002 | typeVersion: 1, 4003 | position: [100, 100] as [number, number], 4004 | parameters: { value: 'old' } 4005 | } 4006 | ] 4007 | }; 4008 | 4009 | const operation: UpdateNodeOperation = { 4010 | type: 'updateNode', 4011 | nodeName: "Update 'this' node", 4012 | updates: { 4013 | 'parameters.value': 'new' 4014 | } 4015 | }; 4016 | 4017 | const request: WorkflowDiffRequest = { 4018 | id: 'test-workflow', 4019 | operations: [operation] 4020 | }; 4021 | 4022 | const result = await diffEngine.applyDiff(workflowWithSpecialNode as Workflow, request); 4023 | 4024 | expect(result.success).toBe(true); 4025 | const updatedNode = result.workflow.nodes.find((n: any) => n.name === "Update 'this' node"); 4026 | expect(updatedNode?.parameters.value).toBe('new'); 4027 | }); 4028 | 4029 | // Code Review Fix: Test whitespace normalization 4030 | it('should handle tabs in node names', async () => { 4031 | const workflowWithTabs = { 4032 | ...baseWorkflow, 4033 | nodes: [ 4034 | ...baseWorkflow.nodes, 4035 | { 4036 | id: 'tab-node-1', 4037 | name: "Node\twith\ttabs", // Contains tabs 4038 | type: 'n8n-nodes-base.set', 4039 | typeVersion: 1, 4040 | position: [100, 100] as [number, number], 4041 | parameters: {} 4042 | } 4043 | ] 4044 | }; 4045 | 4046 | const operation: AddConnectionOperation = { 4047 | type: 'addConnection', 4048 | source: "Node\twith\ttabs", // Tabs should normalize to single spaces 4049 | target: 'HTTP Request' 4050 | }; 4051 | 4052 | const request: WorkflowDiffRequest = { 4053 | id: 'test-workflow', 4054 | operations: [operation] 4055 | }; 4056 | 4057 | const result = await diffEngine.applyDiff(workflowWithTabs as Workflow, request); 4058 | 4059 | expect(result.success).toBe(true); 4060 | // After normalization, both "Node\twith\ttabs" and "Node with tabs" should match 4061 | expect(result.workflow.connections["Node\twith\ttabs"]).toBeDefined(); 4062 | }); 4063 | 4064 | it('should handle newlines in node names', async () => { 4065 | const workflowWithNewlines = { 4066 | ...baseWorkflow, 4067 | nodes: [ 4068 | ...baseWorkflow.nodes, 4069 | { 4070 | id: 'newline-node-1', 4071 | name: "Node\nwith\nnewlines", // Contains newlines 4072 | type: 'n8n-nodes-base.set', 4073 | typeVersion: 1, 4074 | position: [100, 100] as [number, number], 4075 | parameters: {} 4076 | } 4077 | ] 4078 | }; 4079 | 4080 | const operation: AddConnectionOperation = { 4081 | type: 'addConnection', 4082 | source: "Node\nwith\nnewlines", // Newlines should normalize to single spaces 4083 | target: 'HTTP Request' 4084 | }; 4085 | 4086 | const request: WorkflowDiffRequest = { 4087 | id: 'test-workflow', 4088 | operations: [operation] 4089 | }; 4090 | 4091 | const result = await diffEngine.applyDiff(workflowWithNewlines as Workflow, request); 4092 | 4093 | expect(result.success).toBe(true); 4094 | expect(result.workflow.connections["Node\nwith\nnewlines"]).toBeDefined(); 4095 | }); 4096 | 4097 | it('should handle mixed whitespace (tabs, newlines, spaces)', async () => { 4098 | const workflowWithMixed = { 4099 | ...baseWorkflow, 4100 | nodes: [ 4101 | ...baseWorkflow.nodes, 4102 | { 4103 | id: 'mixed-whitespace-node-1', 4104 | name: "Node\t \n with \r\nmixed", // Mixed whitespace 4105 | type: 'n8n-nodes-base.set', 4106 | typeVersion: 1, 4107 | position: [100, 100] as [number, number], 4108 | parameters: {} 4109 | } 4110 | ] 4111 | }; 4112 | 4113 | const operation: AddConnectionOperation = { 4114 | type: 'addConnection', 4115 | source: "Node\t \n with \r\nmixed", // Should normalize all whitespace 4116 | target: 'HTTP Request' 4117 | }; 4118 | 4119 | const request: WorkflowDiffRequest = { 4120 | id: 'test-workflow', 4121 | operations: [operation] 4122 | }; 4123 | 4124 | const result = await diffEngine.applyDiff(workflowWithMixed as Workflow, request); 4125 | 4126 | expect(result.success).toBe(true); 4127 | expect(result.workflow.connections["Node\t \n with \r\nmixed"]).toBeDefined(); 4128 | }); 4129 | 4130 | // Code Review Fix: Test escaped vs unescaped matching (core issue #270 scenario) 4131 | it('should match escaped input with unescaped stored names (Issue #270 core scenario)', async () => { 4132 | // Scenario: AI/JSON-RPC sends escaped name, n8n workflow has unescaped name 4133 | const workflowWithUnescaped = { 4134 | ...baseWorkflow, 4135 | nodes: [ 4136 | ...baseWorkflow.nodes, 4137 | { 4138 | id: 'test-node', 4139 | name: "When clicking 'Execute workflow'", // Unescaped (how n8n stores it) 4140 | type: 'n8n-nodes-base.manualTrigger', 4141 | typeVersion: 1, 4142 | position: [100, 100] as [number, number], 4143 | parameters: {} 4144 | } 4145 | ] 4146 | }; 4147 | 4148 | const operation: AddConnectionOperation = { 4149 | type: 'addConnection', 4150 | source: "When clicking \\'Execute workflow\\'", // Escaped (how JSON-RPC might send it) 4151 | target: 'HTTP Request' 4152 | }; 4153 | 4154 | const request: WorkflowDiffRequest = { 4155 | id: 'test-workflow', 4156 | operations: [operation] 4157 | }; 4158 | 4159 | const result = await diffEngine.applyDiff(workflowWithUnescaped as Workflow, request); 4160 | 4161 | expect(result.success).toBe(true); // Should match despite different escaping 4162 | expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined(); 4163 | }); 4164 | }); 4165 | }); ```