This is page 64 of 67. 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
├── ANALYSIS_QUICK_REFERENCE.md
├── ATTRIBUTION.md
├── CHANGELOG.md
├── CLAUDE.md
├── codecov.yml
├── coverage.json
├── data
│ ├── .gitkeep
│ ├── nodes.db
│ ├── nodes.db-shm
│ ├── nodes.db-wal
│ └── templates.db
├── deploy
│ └── quick-deploy-n8n.sh
├── docker
│ ├── docker-entrypoint.sh
│ ├── n8n-mcp
│ ├── parse-config.js
│ └── README.md
├── docker-compose.buildkit.yml
├── docker-compose.extract.yml
├── docker-compose.n8n.yml
├── docker-compose.override.yml.example
├── docker-compose.test-n8n.yml
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.railway
├── Dockerfile.test
├── docs
│ ├── AUTOMATED_RELEASES.md
│ ├── BENCHMARKS.md
│ ├── CHANGELOG.md
│ ├── CI_TEST_INFRASTRUCTURE.md
│ ├── CLAUDE_CODE_SETUP.md
│ ├── CLAUDE_INTERVIEW.md
│ ├── CODECOV_SETUP.md
│ ├── CODEX_SETUP.md
│ ├── CURSOR_SETUP.md
│ ├── DEPENDENCY_UPDATES.md
│ ├── DOCKER_README.md
│ ├── DOCKER_TROUBLESHOOTING.md
│ ├── FINAL_AI_VALIDATION_SPEC.md
│ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md
│ ├── HTTP_DEPLOYMENT.md
│ ├── img
│ │ ├── cc_command.png
│ │ ├── cc_connected.png
│ │ ├── codex_connected.png
│ │ ├── cursor_tut.png
│ │ ├── Railway_api.png
│ │ ├── Railway_server_address.png
│ │ ├── skills.png
│ │ ├── vsc_ghcp_chat_agent_mode.png
│ │ ├── vsc_ghcp_chat_instruction_files.png
│ │ ├── vsc_ghcp_chat_thinking_tool.png
│ │ └── windsurf_tut.png
│ ├── INSTALLATION.md
│ ├── LIBRARY_USAGE.md
│ ├── local
│ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md
│ │ ├── DEEP_DIVE_ANALYSIS_README.md
│ │ ├── Deep_dive_p1_p2.md
│ │ ├── integration-testing-plan.md
│ │ ├── integration-tests-phase1-summary.md
│ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
│ │ ├── P0_IMPLEMENTATION_PLAN.md
│ │ └── TEMPLATE_MINING_ANALYSIS.md
│ ├── MCP_ESSENTIALS_README.md
│ ├── MCP_QUICK_START_GUIDE.md
│ ├── N8N_DEPLOYMENT.md
│ ├── RAILWAY_DEPLOYMENT.md
│ ├── README_CLAUDE_SETUP.md
│ ├── README.md
│ ├── SESSION_PERSISTENCE.md
│ ├── tools-documentation-usage.md
│ ├── TYPE_STRUCTURE_VALIDATION.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_ANALYSIS.md
├── README.md
├── renovate.json
├── scripts
│ ├── analyze-optimization.sh
│ ├── audit-schema-coverage.ts
│ ├── backfill-mutation-hashes.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-initial-release-notes.js
│ ├── generate-release-notes.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
│ ├── process-batch-metadata.ts
│ ├── 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-structure-validation.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
│ ├── test-workflow-versioning.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
│ ├── constants
│ │ └── type-structures.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.ts
│ │ │ │ └── index.ts
│ │ │ ├── discovery
│ │ │ │ ├── index.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
│ │ │ │ ├── index.ts
│ │ │ │ └── search-templates.ts
│ │ │ ├── types.ts
│ │ │ ├── validation
│ │ │ │ ├── index.ts
│ │ │ │ ├── validate-node.ts
│ │ │ │ └── validate-workflow.ts
│ │ │ └── workflow_management
│ │ │ ├── index.ts
│ │ │ ├── n8n-autofix-workflow.ts
│ │ │ ├── n8n-create-workflow.ts
│ │ │ ├── n8n-delete-workflow.ts
│ │ │ ├── n8n-executions.ts
│ │ │ ├── n8n-get-workflow.ts
│ │ │ ├── n8n-list-workflows.ts
│ │ │ ├── n8n-trigger-webhook-workflow.ts
│ │ │ ├── n8n-update-full-workflow.ts
│ │ │ ├── n8n-update-partial-workflow.ts
│ │ │ ├── n8n-validate-workflow.ts
│ │ │ └── n8n-workflow-versions.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-telemetry-mutations-verbose.ts
│ │ ├── test-telemetry-mutations.ts
│ │ ├── test-webhook-autofix.ts
│ │ ├── validate.ts
│ │ └── validation-summary.ts
│ ├── services
│ │ ├── ai-node-validator.ts
│ │ ├── ai-tool-validators.ts
│ │ ├── breaking-change-detector.ts
│ │ ├── breaking-changes-registry.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-migration-service.ts
│ │ ├── node-sanitizer.ts
│ │ ├── node-similarity-service.ts
│ │ ├── node-specific-validators.ts
│ │ ├── node-version-service.ts
│ │ ├── operation-similarity-service.ts
│ │ ├── post-update-validator.ts
│ │ ├── property-dependencies.ts
│ │ ├── property-filter.ts
│ │ ├── resource-similarity-service.ts
│ │ ├── sqlite-storage-service.ts
│ │ ├── task-templates.ts
│ │ ├── type-structure-service.ts
│ │ ├── universal-expression-validator.ts
│ │ ├── workflow-auto-fixer.ts
│ │ ├── workflow-diff-engine.ts
│ │ ├── workflow-validator.ts
│ │ └── workflow-versioning-service.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
│ │ ├── intent-classifier.ts
│ │ ├── intent-sanitizer.ts
│ │ ├── mutation-tracker.ts
│ │ ├── mutation-types.ts
│ │ ├── mutation-validator.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
│ │ ├── session-state.ts
│ │ ├── type-structures.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
│ ├── expression-utils.ts
│ ├── fixed-collection-validator.ts
│ ├── logger.ts
│ ├── mcp-client.ts
│ ├── n8n-errors.ts
│ ├── node-classification.ts
│ ├── node-source-extractor.ts
│ ├── node-type-normalizer.ts
│ ├── node-type-utils.ts
│ ├── node-utils.ts
│ ├── npm-version-checker.ts
│ ├── protocol-version.ts
│ ├── simple-cache.ts
│ ├── ssrf-protection.ts
│ ├── template-node-resolver.ts
│ ├── template-sanitizer.ts
│ ├── url-detector.ts
│ ├── validation-schemas.ts
│ └── version.ts
├── test-output.txt
├── test-reinit-fix.sh
├── tests
│ ├── __snapshots__
│ │ └── .gitkeep
│ ├── auth.test.ts
│ ├── benchmarks
│ │ ├── database-queries.bench.ts
│ │ ├── index.ts
│ │ ├── mcp-tools.bench.ts
│ │ ├── mcp-tools.bench.ts.disabled
│ │ ├── mcp-tools.bench.ts.skip
│ │ ├── node-loading.bench.ts.disabled
│ │ ├── README.md
│ │ ├── search-operations.bench.ts.disabled
│ │ └── validation-performance.bench.ts.disabled
│ ├── bridge.test.ts
│ ├── comprehensive-extraction-test.js
│ ├── data
│ │ └── .gitkeep
│ ├── debug-slack-doc.js
│ ├── demo-enhanced-documentation.js
│ ├── docker-tests-README.md
│ ├── error-handler.test.ts
│ ├── examples
│ │ └── using-database-utils.test.ts
│ ├── extracted-nodes-db
│ │ ├── database-import.json
│ │ ├── extraction-report.json
│ │ ├── insert-nodes.sql
│ │ ├── n8n-nodes-base__Airtable.json
│ │ ├── n8n-nodes-base__Discord.json
│ │ ├── n8n-nodes-base__Function.json
│ │ ├── n8n-nodes-base__HttpRequest.json
│ │ ├── n8n-nodes-base__If.json
│ │ ├── n8n-nodes-base__Slack.json
│ │ ├── n8n-nodes-base__SplitInBatches.json
│ │ └── n8n-nodes-base__Webhook.json
│ ├── factories
│ │ ├── node-factory.ts
│ │ └── property-definition-factory.ts
│ ├── fixtures
│ │ ├── .gitkeep
│ │ ├── database
│ │ │ └── test-nodes.json
│ │ ├── factories
│ │ │ ├── node.factory.ts
│ │ │ └── parser-node.factory.ts
│ │ └── template-configs.ts
│ ├── helpers
│ │ └── env-helpers.ts
│ ├── http-server-auth.test.ts
│ ├── integration
│ │ ├── ai-validation
│ │ │ ├── ai-agent-validation.test.ts
│ │ │ ├── ai-tool-validation.test.ts
│ │ │ ├── chat-trigger-validation.test.ts
│ │ │ ├── e2e-validation.test.ts
│ │ │ ├── helpers.ts
│ │ │ ├── llm-chain-validation.test.ts
│ │ │ ├── README.md
│ │ │ └── TEST_REPORT.md
│ │ ├── ci
│ │ │ └── database-population.test.ts
│ │ ├── database
│ │ │ ├── connection-management.test.ts
│ │ │ ├── empty-database.test.ts
│ │ │ ├── fts5-search.test.ts
│ │ │ ├── node-fts5-search.test.ts
│ │ │ ├── node-repository.test.ts
│ │ │ ├── performance.test.ts
│ │ │ ├── sqljs-memory-leak.test.ts
│ │ │ ├── template-node-configs.test.ts
│ │ │ ├── template-repository.test.ts
│ │ │ ├── test-utils.ts
│ │ │ └── transactions.test.ts
│ │ ├── database-integration.test.ts
│ │ ├── docker
│ │ │ ├── docker-config.test.ts
│ │ │ ├── docker-entrypoint.test.ts
│ │ │ └── test-helpers.ts
│ │ ├── flexible-instance-config.test.ts
│ │ ├── mcp
│ │ │ └── template-examples-e2e.test.ts
│ │ ├── mcp-protocol
│ │ │ ├── basic-connection.test.ts
│ │ │ ├── error-handling.test.ts
│ │ │ ├── performance.test.ts
│ │ │ ├── protocol-compliance.test.ts
│ │ │ ├── README.md
│ │ │ ├── session-management.test.ts
│ │ │ ├── test-helpers.ts
│ │ │ ├── tool-invocation.test.ts
│ │ │ └── workflow-error-validation.test.ts
│ │ ├── msw-setup.test.ts
│ │ ├── n8n-api
│ │ │ ├── executions
│ │ │ │ ├── delete-execution.test.ts
│ │ │ │ ├── get-execution.test.ts
│ │ │ │ ├── list-executions.test.ts
│ │ │ │ └── trigger-webhook.test.ts
│ │ │ ├── scripts
│ │ │ │ └── cleanup-orphans.ts
│ │ │ ├── system
│ │ │ │ ├── diagnostic.test.ts
│ │ │ │ └── health-check.test.ts
│ │ │ ├── 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
│ │ ├── validation
│ │ │ └── real-world-structure-validation.test.ts
│ │ ├── workflow-creation-node-type-format.test.ts
│ │ └── workflow-diff
│ │ ├── ai-node-connection-validation.test.ts
│ │ └── node-rename-integration.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
│ │ ├── constants
│ │ │ └── type-structures.test.ts
│ │ ├── 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
│ │ │ └── session-persistence.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
│ │ │ ├── disabled-tools-additional.test.ts
│ │ │ ├── disabled-tools.test.ts
│ │ │ ├── get-node-essentials-examples.test.ts
│ │ │ ├── get-node-unified.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
│ │ ├── mcp-engine
│ │ │ └── session-persistence.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
│ │ │ ├── breaking-change-detector.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-type-structures.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-sticky-notes.test.ts
│ │ │ ├── n8n-validation.test.ts
│ │ │ ├── node-migration-service.test.ts
│ │ │ ├── node-sanitizer.test.ts
│ │ │ ├── node-similarity-service.test.ts
│ │ │ ├── node-specific-validators.test.ts
│ │ │ ├── node-version-service.test.ts
│ │ │ ├── operation-similarity-service-comprehensive.test.ts
│ │ │ ├── operation-similarity-service.test.ts
│ │ │ ├── post-update-validator.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
│ │ │ ├── type-structure-service.test.ts
│ │ │ ├── universal-expression-validator.test.ts
│ │ │ ├── validation-fixes.test.ts
│ │ │ ├── workflow-auto-fixer.test.ts
│ │ │ ├── workflow-diff-engine.test.ts
│ │ │ ├── workflow-diff-node-rename.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
│ │ │ └── workflow-versioning-service.test.ts
│ │ ├── telemetry
│ │ │ ├── batch-processor.test.ts
│ │ │ ├── config-manager.test.ts
│ │ │ ├── event-tracker.test.ts
│ │ │ ├── event-validator.test.ts
│ │ │ ├── mutation-tracker.test.ts
│ │ │ ├── mutation-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
│ │ │ └── type-structures.test.ts
│ │ ├── utils
│ │ │ ├── auth-timing-safe.test.ts
│ │ │ ├── cache-utils.test.ts
│ │ │ ├── console-manager.test.ts
│ │ │ ├── database-utils.test.ts
│ │ │ ├── expression-utils.test.ts
│ │ │ ├── fixed-collection-validator.test.ts
│ │ │ ├── n8n-errors.test.ts
│ │ │ ├── node-classification.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
├── 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 | it('should provide helpful error when using "changes" instead of "updates" (Issue #392)', async () => {
389 | // Simulate the common mistake of using "changes" instead of "updates"
390 | const operation: any = {
391 | type: 'updateNode',
392 | nodeId: 'http-1',
393 | changes: { // Wrong property name
394 | 'parameters.url': 'https://example.com'
395 | }
396 | };
397 |
398 | const request: WorkflowDiffRequest = {
399 | id: 'test-workflow',
400 | operations: [operation]
401 | };
402 |
403 | const result = await diffEngine.applyDiff(baseWorkflow, request);
404 |
405 | expect(result.success).toBe(false);
406 | expect(result.errors![0].message).toContain('Invalid parameter \'changes\'');
407 | expect(result.errors![0].message).toContain('requires \'updates\'');
408 | expect(result.errors![0].message).toContain('Example:');
409 | });
410 |
411 | it('should provide helpful error when "updates" parameter is missing', async () => {
412 | const operation: any = {
413 | type: 'updateNode',
414 | nodeId: 'http-1'
415 | // Missing "updates" property
416 | };
417 |
418 | const request: WorkflowDiffRequest = {
419 | id: 'test-workflow',
420 | operations: [operation]
421 | };
422 |
423 | const result = await diffEngine.applyDiff(baseWorkflow, request);
424 |
425 | expect(result.success).toBe(false);
426 | expect(result.errors![0].message).toContain('Missing required parameter \'updates\'');
427 | expect(result.errors![0].message).toContain('Example:');
428 | });
429 | });
430 |
431 | describe('MoveNode Operation', () => {
432 | it('should move node to new position', async () => {
433 | const operation: MoveNodeOperation = {
434 | type: 'moveNode',
435 | nodeId: 'http-1',
436 | position: [1000, 500]
437 | };
438 |
439 | const request: WorkflowDiffRequest = {
440 | id: 'test-workflow',
441 | operations: [operation]
442 | };
443 |
444 | const result = await diffEngine.applyDiff(baseWorkflow, request);
445 |
446 | expect(result.success).toBe(true);
447 | const movedNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1');
448 | expect(movedNode!.position).toEqual([1000, 500]);
449 | });
450 |
451 | it('should move node by name', async () => {
452 | const operation: MoveNodeOperation = {
453 | type: 'moveNode',
454 | nodeName: 'Webhook',
455 | position: [100, 100]
456 | };
457 |
458 | const request: WorkflowDiffRequest = {
459 | id: 'test-workflow',
460 | operations: [operation]
461 | };
462 |
463 | const result = await diffEngine.applyDiff(baseWorkflow, request);
464 |
465 | expect(result.success).toBe(true);
466 | const movedNode = result.workflow!.nodes.find((n: any) => n.name === 'Webhook');
467 | expect(movedNode!.position).toEqual([100, 100]);
468 | });
469 | });
470 |
471 | describe('Enable/Disable Node Operations', () => {
472 | it('should disable a node', async () => {
473 | const operation: DisableNodeOperation = {
474 | type: 'disableNode',
475 | nodeId: 'http-1'
476 | };
477 |
478 | const request: WorkflowDiffRequest = {
479 | id: 'test-workflow',
480 | operations: [operation]
481 | };
482 |
483 | const result = await diffEngine.applyDiff(baseWorkflow, request);
484 |
485 | expect(result.success).toBe(true);
486 | const disabledNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1');
487 | expect(disabledNode!.disabled).toBe(true);
488 | });
489 |
490 | it('should enable a disabled node', async () => {
491 | // First disable the node
492 | baseWorkflow.nodes[1].disabled = true;
493 |
494 | const operation: EnableNodeOperation = {
495 | type: 'enableNode',
496 | nodeId: 'http-1'
497 | };
498 |
499 | const request: WorkflowDiffRequest = {
500 | id: 'test-workflow',
501 | operations: [operation]
502 | };
503 |
504 | const result = await diffEngine.applyDiff(baseWorkflow, request);
505 |
506 | expect(result.success).toBe(true);
507 | const enabledNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1');
508 | expect(enabledNode!.disabled).toBe(false);
509 | });
510 | });
511 |
512 | describe('AddConnection Operation', () => {
513 | it('should add a new connection', async () => {
514 | // First add a new node to connect to
515 | const addNodeOp: AddNodeOperation = {
516 | type: 'addNode',
517 | node: {
518 | name: 'Code',
519 | type: 'n8n-nodes-base.code',
520 | position: [1000, 300]
521 | }
522 | };
523 |
524 | const addConnectionOp: AddConnectionOperation = {
525 | type: 'addConnection',
526 | source: 'slack-1',
527 | target: 'Code'
528 | };
529 |
530 | const request: WorkflowDiffRequest = {
531 | id: 'test-workflow',
532 | operations: [addNodeOp, addConnectionOp]
533 | };
534 |
535 | const result = await diffEngine.applyDiff(baseWorkflow, request);
536 |
537 | expect(result.success).toBe(true);
538 | expect(result.workflow!.connections['Slack']).toBeDefined();
539 | expect(result.workflow!.connections['Slack'].main[0]).toHaveLength(1);
540 | expect(result.workflow!.connections['Slack'].main[0][0].node).toBe('Code');
541 | });
542 |
543 | it('should reject duplicate connections', async () => {
544 | const operation: AddConnectionOperation = {
545 | type: 'addConnection',
546 | source: 'Webhook', // Use node name not ID
547 | target: 'HTTP Request' // Use node name not ID
548 | };
549 |
550 | const request: WorkflowDiffRequest = {
551 | id: 'test-workflow',
552 | operations: [operation]
553 | };
554 |
555 | const result = await diffEngine.applyDiff(baseWorkflow, request);
556 |
557 | expect(result.success).toBe(false);
558 | expect(result.errors![0].message).toContain('Connection already exists');
559 | });
560 |
561 | it('should reject connection to non-existent source node', async () => {
562 | const operation: AddConnectionOperation = {
563 | type: 'addConnection',
564 | source: 'non-existent',
565 | target: 'http-1'
566 | };
567 |
568 | const request: WorkflowDiffRequest = {
569 | id: 'test-workflow',
570 | operations: [operation]
571 | };
572 |
573 | const result = await diffEngine.applyDiff(baseWorkflow, request);
574 |
575 | expect(result.success).toBe(false);
576 | expect(result.errors![0].message).toContain('Source node not found');
577 | });
578 |
579 | it('should reject connection to non-existent target node', async () => {
580 | const operation: AddConnectionOperation = {
581 | type: 'addConnection',
582 | source: 'webhook-1',
583 | target: 'non-existent'
584 | };
585 |
586 | const request: WorkflowDiffRequest = {
587 | id: 'test-workflow',
588 | operations: [operation]
589 | };
590 |
591 | const result = await diffEngine.applyDiff(baseWorkflow, request);
592 |
593 | expect(result.success).toBe(false);
594 | expect(result.errors![0].message).toContain('Target node not found');
595 | });
596 |
597 | it('should support custom output and input types', async () => {
598 | // Add an IF node that has multiple outputs
599 | const addNodeOp: AddNodeOperation = {
600 | type: 'addNode',
601 | node: {
602 | name: 'IF',
603 | type: 'n8n-nodes-base.if',
604 | position: [600, 400]
605 | }
606 | };
607 |
608 | const addConnectionOp: AddConnectionOperation = {
609 | type: 'addConnection',
610 | source: 'IF',
611 | target: 'slack-1',
612 | sourceOutput: 'false',
613 | targetInput: 'main',
614 | sourceIndex: 0,
615 | targetIndex: 0
616 | };
617 |
618 | const request: WorkflowDiffRequest = {
619 | id: 'test-workflow',
620 | operations: [addNodeOp, addConnectionOp]
621 | };
622 |
623 | const result = await diffEngine.applyDiff(baseWorkflow, request);
624 |
625 | expect(result.success).toBe(true);
626 | expect(result.workflow!.connections['IF'].false).toBeDefined();
627 | expect(result.workflow!.connections['IF'].false[0][0].node).toBe('Slack');
628 | });
629 |
630 | it('should reject addConnection with wrong parameter sourceNodeId instead of source (Issue #249)', async () => {
631 | const operation: any = {
632 | type: 'addConnection',
633 | sourceNodeId: 'webhook-1', // Wrong parameter name!
634 | target: 'http-1'
635 | };
636 |
637 | const request: WorkflowDiffRequest = {
638 | id: 'test-workflow',
639 | operations: [operation]
640 | };
641 |
642 | const result = await diffEngine.applyDiff(baseWorkflow, request);
643 |
644 | expect(result.success).toBe(false);
645 | expect(result.errors![0].message).toContain('Invalid parameter(s): sourceNodeId');
646 | expect(result.errors![0].message).toContain("Use 'source' and 'target' instead");
647 | });
648 |
649 | it('should reject addConnection with wrong parameter targetNodeId instead of target (Issue #249)', async () => {
650 | const operation: any = {
651 | type: 'addConnection',
652 | source: 'webhook-1',
653 | targetNodeId: 'http-1' // Wrong parameter name!
654 | };
655 |
656 | const request: WorkflowDiffRequest = {
657 | id: 'test-workflow',
658 | operations: [operation]
659 | };
660 |
661 | const result = await diffEngine.applyDiff(baseWorkflow, request);
662 |
663 | expect(result.success).toBe(false);
664 | expect(result.errors![0].message).toContain('Invalid parameter(s): targetNodeId');
665 | expect(result.errors![0].message).toContain("Use 'source' and 'target' instead");
666 | });
667 |
668 | it('should reject addConnection with both wrong parameters (Issue #249)', async () => {
669 | const operation: any = {
670 | type: 'addConnection',
671 | sourceNodeId: 'webhook-1', // Wrong!
672 | targetNodeId: 'http-1' // Wrong!
673 | };
674 |
675 | const request: WorkflowDiffRequest = {
676 | id: 'test-workflow',
677 | operations: [operation]
678 | };
679 |
680 | const result = await diffEngine.applyDiff(baseWorkflow, request);
681 |
682 | expect(result.success).toBe(false);
683 | expect(result.errors![0].message).toContain('Invalid parameter(s): sourceNodeId, targetNodeId');
684 | expect(result.errors![0].message).toContain("Use 'source' and 'target' instead");
685 | });
686 |
687 | it('should show helpful error with available nodes when source is missing (Issue #249)', async () => {
688 | const operation: any = {
689 | type: 'addConnection',
690 | // source is missing entirely
691 | target: 'http-1'
692 | };
693 |
694 | const request: WorkflowDiffRequest = {
695 | id: 'test-workflow',
696 | operations: [operation]
697 | };
698 |
699 | const result = await diffEngine.applyDiff(baseWorkflow, request);
700 |
701 | expect(result.success).toBe(false);
702 | expect(result.errors![0].message).toContain("Missing required parameter 'source'");
703 | expect(result.errors![0].message).toContain("not 'sourceNodeId'");
704 | });
705 |
706 | it('should show helpful error with available nodes when target is missing (Issue #249)', async () => {
707 | const operation: any = {
708 | type: 'addConnection',
709 | source: 'webhook-1',
710 | // target is missing entirely
711 | };
712 |
713 | const request: WorkflowDiffRequest = {
714 | id: 'test-workflow',
715 | operations: [operation]
716 | };
717 |
718 | const result = await diffEngine.applyDiff(baseWorkflow, request);
719 |
720 | expect(result.success).toBe(false);
721 | expect(result.errors![0].message).toContain("Missing required parameter 'target'");
722 | expect(result.errors![0].message).toContain("not 'targetNodeId'");
723 | });
724 |
725 | it('should list available nodes when source node not found (Issue #249)', async () => {
726 | const operation: AddConnectionOperation = {
727 | type: 'addConnection',
728 | source: 'non-existent-node',
729 | target: 'http-1'
730 | };
731 |
732 | const request: WorkflowDiffRequest = {
733 | id: 'test-workflow',
734 | operations: [operation]
735 | };
736 |
737 | const result = await diffEngine.applyDiff(baseWorkflow, request);
738 |
739 | expect(result.success).toBe(false);
740 | expect(result.errors![0].message).toContain('Source node not found: "non-existent-node"');
741 | expect(result.errors![0].message).toContain('Available nodes:');
742 | expect(result.errors![0].message).toContain('Webhook');
743 | expect(result.errors![0].message).toContain('HTTP Request');
744 | expect(result.errors![0].message).toContain('Slack');
745 | });
746 |
747 | it('should list available nodes when target node not found (Issue #249)', async () => {
748 | const operation: AddConnectionOperation = {
749 | type: 'addConnection',
750 | source: 'webhook-1',
751 | target: 'non-existent-node'
752 | };
753 |
754 | const request: WorkflowDiffRequest = {
755 | id: 'test-workflow',
756 | operations: [operation]
757 | };
758 |
759 | const result = await diffEngine.applyDiff(baseWorkflow, request);
760 |
761 | expect(result.success).toBe(false);
762 | expect(result.errors![0].message).toContain('Target node not found: "non-existent-node"');
763 | expect(result.errors![0].message).toContain('Available nodes:');
764 | expect(result.errors![0].message).toContain('Webhook');
765 | expect(result.errors![0].message).toContain('HTTP Request');
766 | expect(result.errors![0].message).toContain('Slack');
767 | });
768 | });
769 |
770 | describe('RemoveConnection Operation', () => {
771 | it('should remove an existing connection', async () => {
772 | const operation: RemoveConnectionOperation = {
773 | type: 'removeConnection',
774 | source: 'Webhook', // Use node name
775 | target: 'HTTP Request' // Use node name
776 | };
777 |
778 | const request: WorkflowDiffRequest = {
779 | id: 'test-workflow',
780 | operations: [operation]
781 | };
782 |
783 | const result = await diffEngine.applyDiff(baseWorkflow, request);
784 |
785 | expect(result.success).toBe(true);
786 | // After removing the connection, the array should be empty or cleaned up
787 | if (result.workflow!.connections['Webhook']) {
788 | if (result.workflow!.connections['Webhook'].main && result.workflow!.connections['Webhook'].main.length > 0) {
789 | expect(result.workflow!.connections['Webhook'].main[0]).toHaveLength(0);
790 | } else {
791 | expect(result.workflow!.connections['Webhook'].main).toHaveLength(0);
792 | }
793 | } else {
794 | // Connection was cleaned up entirely
795 | expect(result.workflow!.connections['Webhook']).toBeUndefined();
796 | }
797 | });
798 |
799 | it('should reject removing non-existent connection', async () => {
800 | const operation: RemoveConnectionOperation = {
801 | type: 'removeConnection',
802 | source: 'Slack', // Use node name
803 | target: 'Webhook' // Use node name
804 | };
805 |
806 | const request: WorkflowDiffRequest = {
807 | id: 'test-workflow',
808 | operations: [operation]
809 | };
810 |
811 | const result = await diffEngine.applyDiff(baseWorkflow, request);
812 |
813 | expect(result.success).toBe(false);
814 | expect(result.errors![0].message).toContain('No connections found');
815 | });
816 | });
817 |
818 |
819 | describe('RewireConnection Operation (Phase 1)', () => {
820 | it('should rewire connection from one target to another', async () => {
821 | // Setup: Create a connection Webhook → HTTP Request
822 | // Then rewire it to Webhook → Slack instead
823 | const rewireOp: any = {
824 | type: 'rewireConnection',
825 | source: 'Webhook',
826 | from: 'HTTP Request',
827 | to: 'Slack'
828 | };
829 |
830 | const request: WorkflowDiffRequest = {
831 | id: 'test-workflow',
832 | operations: [rewireOp]
833 | };
834 |
835 | const result = await diffEngine.applyDiff(baseWorkflow, request);
836 |
837 | expect(result.success).toBe(true);
838 | expect(result.workflow).toBeDefined();
839 |
840 | // Old connection should be removed
841 | const webhookConnections = result.workflow!.connections['Webhook']['main'][0];
842 | expect(webhookConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false);
843 |
844 | // New connection should exist
845 | expect(webhookConnections.some((c: any) => c.node === 'Slack')).toBe(true);
846 | });
847 |
848 | it('should rewire connection with specified sourceOutput', async () => {
849 | // Add IF node with connection on 'true' output
850 | const addNode: AddNodeOperation = {
851 | type: 'addNode',
852 | node: {
853 | name: 'IF',
854 | type: 'n8n-nodes-base.if',
855 | position: [600, 300]
856 | }
857 | };
858 |
859 | const addConn: AddConnectionOperation = {
860 | type: 'addConnection',
861 | source: 'IF',
862 | target: 'HTTP Request',
863 | sourceOutput: 'true'
864 | };
865 |
866 | const rewire: any = {
867 | type: 'rewireConnection',
868 | source: 'IF',
869 | from: 'HTTP Request',
870 | to: 'Slack',
871 | sourceOutput: 'true'
872 | };
873 |
874 | const request: WorkflowDiffRequest = {
875 | id: 'test-workflow',
876 | operations: [addNode, addConn, rewire]
877 | };
878 |
879 | const result = await diffEngine.applyDiff(baseWorkflow, request);
880 |
881 | expect(result.success).toBe(true);
882 |
883 | // Verify rewiring on 'true' output
884 | const trueConnections = result.workflow!.connections['IF']['true'][0];
885 | expect(trueConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false);
886 | expect(trueConnections.some((c: any) => c.node === 'Slack')).toBe(true);
887 | });
888 |
889 | it('should preserve other parallel connections when rewiring', async () => {
890 | // Setup: Webhook connects to both HTTP Request (in baseWorkflow) and Slack (added here)
891 | // Add a Set node, then rewire HTTP Request → Set
892 | // Slack connection should remain unchanged
893 |
894 | // Add Slack connection in parallel
895 | const addSlackConn: AddConnectionOperation = {
896 | type: 'addConnection',
897 | source: 'Webhook',
898 | target: 'Slack'
899 | };
900 |
901 | // Add Set node to rewire to
902 | const addSetNode: AddNodeOperation = {
903 | type: 'addNode',
904 | node: {
905 | name: 'Set',
906 | type: 'n8n-nodes-base.set',
907 | position: [800, 300]
908 | }
909 | };
910 |
911 | // Rewire HTTP Request → Set
912 | const rewire: any = {
913 | type: 'rewireConnection',
914 | source: 'Webhook',
915 | from: 'HTTP Request',
916 | to: 'Set'
917 | };
918 |
919 | const request: WorkflowDiffRequest = {
920 | id: 'test-workflow',
921 | operations: [addSlackConn, addSetNode, rewire]
922 | };
923 |
924 | const result = await diffEngine.applyDiff(baseWorkflow, request);
925 |
926 | expect(result.success).toBe(true);
927 |
928 | const webhookConnections = result.workflow!.connections['Webhook']['main'][0];
929 |
930 | // HTTP Request should be removed
931 | expect(webhookConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false);
932 |
933 | // Set should be added
934 | expect(webhookConnections.some((c: any) => c.node === 'Set')).toBe(true);
935 |
936 | // Slack should still be there (parallel connection preserved)
937 | expect(webhookConnections.some((c: any) => c.node === 'Slack')).toBe(true);
938 | });
939 |
940 | it('should reject rewireConnection when source node not found', async () => {
941 | const rewire: any = {
942 | type: 'rewireConnection',
943 | source: 'NonExistent',
944 | from: 'HTTP Request',
945 | to: 'Slack'
946 | };
947 |
948 | const request: WorkflowDiffRequest = {
949 | id: 'test-workflow',
950 | operations: [rewire]
951 | };
952 |
953 | const result = await diffEngine.applyDiff(baseWorkflow, request);
954 |
955 | expect(result.success).toBe(false);
956 | expect(result.errors).toBeDefined();
957 | expect(result.errors![0].message).toContain('Source node not found');
958 | expect(result.errors![0].message).toContain('NonExistent');
959 | expect(result.errors![0].message).toContain('Available nodes');
960 | });
961 |
962 | it('should reject rewireConnection when "from" node not found', async () => {
963 | const rewire: any = {
964 | type: 'rewireConnection',
965 | source: 'Webhook',
966 | from: 'NonExistent',
967 | to: 'Slack'
968 | };
969 |
970 | const request: WorkflowDiffRequest = {
971 | id: 'test-workflow',
972 | operations: [rewire]
973 | };
974 |
975 | const result = await diffEngine.applyDiff(baseWorkflow, request);
976 |
977 | expect(result.success).toBe(false);
978 | expect(result.errors).toBeDefined();
979 | expect(result.errors![0].message).toContain('"From" node not found');
980 | expect(result.errors![0].message).toContain('NonExistent');
981 | });
982 |
983 | it('should reject rewireConnection when "to" node not found', async () => {
984 | const rewire: any = {
985 | type: 'rewireConnection',
986 | source: 'Webhook',
987 | from: 'HTTP Request',
988 | to: 'NonExistent'
989 | };
990 |
991 | const request: WorkflowDiffRequest = {
992 | id: 'test-workflow',
993 | operations: [rewire]
994 | };
995 |
996 | const result = await diffEngine.applyDiff(baseWorkflow, request);
997 |
998 | expect(result.success).toBe(false);
999 | expect(result.errors).toBeDefined();
1000 | expect(result.errors![0].message).toContain('"To" node not found');
1001 | expect(result.errors![0].message).toContain('NonExistent');
1002 | });
1003 |
1004 | it('should reject rewireConnection when connection does not exist', async () => {
1005 | // Slack node exists but doesn't have any outgoing connections
1006 | // So this should fail with "No connections found" error
1007 | const rewire: any = {
1008 | type: 'rewireConnection',
1009 | source: 'Slack', // Slack has no outgoing connections in baseWorkflow
1010 | from: 'HTTP Request',
1011 | to: 'Webhook' // Use existing node
1012 | };
1013 |
1014 | const request: WorkflowDiffRequest = {
1015 | id: 'test-workflow',
1016 | operations: [rewire]
1017 | };
1018 |
1019 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1020 |
1021 | expect(result.success).toBe(false);
1022 | expect(result.errors).toBeDefined();
1023 | expect(result.errors![0].message).toContain('No connections found from');
1024 | expect(result.errors![0].message).toContain('Slack');
1025 | });
1026 |
1027 | it('should handle rewiring IF node branches correctly', async () => {
1028 | // Add IF node with true/false branches
1029 | const addIF: AddNodeOperation = {
1030 | type: 'addNode',
1031 | node: {
1032 | name: 'IF',
1033 | type: 'n8n-nodes-base.if',
1034 | position: [600, 300]
1035 | }
1036 | };
1037 |
1038 | const addSuccess: AddNodeOperation = {
1039 | type: 'addNode',
1040 | node: {
1041 | name: 'SuccessHandler',
1042 | type: 'n8n-nodes-base.set',
1043 | position: [800, 200]
1044 | }
1045 | };
1046 |
1047 | const addError: AddNodeOperation = {
1048 | type: 'addNode',
1049 | node: {
1050 | name: 'ErrorHandler',
1051 | type: 'n8n-nodes-base.set',
1052 | position: [800, 400]
1053 | }
1054 | };
1055 |
1056 | const connectTrue: AddConnectionOperation = {
1057 | type: 'addConnection',
1058 | source: 'IF',
1059 | target: 'SuccessHandler',
1060 | sourceOutput: 'true'
1061 | };
1062 |
1063 | const connectFalse: AddConnectionOperation = {
1064 | type: 'addConnection',
1065 | source: 'IF',
1066 | target: 'ErrorHandler',
1067 | sourceOutput: 'false'
1068 | };
1069 |
1070 | // Rewire the false branch to go to SuccessHandler instead
1071 | const rewireFalse: any = {
1072 | type: 'rewireConnection',
1073 | source: 'IF',
1074 | from: 'ErrorHandler',
1075 | to: 'Slack',
1076 | sourceOutput: 'false'
1077 | };
1078 |
1079 | const request: WorkflowDiffRequest = {
1080 | id: 'test-workflow',
1081 | operations: [addIF, addSuccess, addError, connectTrue, connectFalse, rewireFalse]
1082 | };
1083 |
1084 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1085 |
1086 | expect(result.success).toBe(true);
1087 |
1088 | // True branch should still point to SuccessHandler
1089 | expect(result.workflow!.connections['IF']['true'][0][0].node).toBe('SuccessHandler');
1090 |
1091 | // False branch should now point to Slack
1092 | expect(result.workflow!.connections['IF']['false'][0][0].node).toBe('Slack');
1093 | });
1094 | });
1095 |
1096 | describe('Smart Parameters (Phase 1)', () => {
1097 | it('should use branch="true" for IF node connections', async () => {
1098 | // Add IF node
1099 | const addIF: any = {
1100 | type: 'addNode',
1101 | node: {
1102 | name: 'IF',
1103 | type: 'n8n-nodes-base.if',
1104 | position: [400, 300]
1105 | }
1106 | };
1107 |
1108 | // Add TrueHandler node (use unique name)
1109 | const addTrueHandler: any = {
1110 | type: 'addNode',
1111 | node: {
1112 | name: 'TrueHandler',
1113 | type: 'n8n-nodes-base.set',
1114 | position: [600, 300]
1115 | }
1116 | };
1117 |
1118 | // Connect IF to TrueHandler using smart branch parameter
1119 | const connectWithBranch: any = {
1120 | type: 'addConnection',
1121 | source: 'IF',
1122 | target: 'TrueHandler',
1123 | branch: 'true' // Smart parameter instead of sourceOutput: 'true'
1124 | };
1125 |
1126 | const request: WorkflowDiffRequest = {
1127 | id: 'test-workflow',
1128 | operations: [addIF, addTrueHandler, connectWithBranch]
1129 | };
1130 |
1131 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1132 |
1133 | expect(result.success).toBe(true);
1134 | expect(result.workflow).toBeDefined();
1135 |
1136 | // Should create connection on 'main' output, index 0 (true branch)
1137 | expect(result.workflow!.connections['IF']['main']).toBeDefined();
1138 | expect(result.workflow!.connections['IF']['main'][0]).toBeDefined();
1139 | expect(result.workflow!.connections['IF']['main'][0][0].node).toBe('TrueHandler');
1140 | });
1141 |
1142 | it('should use branch="false" for IF node connections', async () => {
1143 | const addIF: any = {
1144 | type: 'addNode',
1145 | node: {
1146 | name: 'IF',
1147 | type: 'n8n-nodes-base.if',
1148 | position: [400, 300]
1149 | }
1150 | };
1151 |
1152 | const addFalseHandler: any = {
1153 | type: 'addNode',
1154 | node: {
1155 | name: 'FalseHandler',
1156 | type: 'n8n-nodes-base.set',
1157 | position: [600, 300]
1158 | }
1159 | };
1160 |
1161 | const connectWithBranch: any = {
1162 | type: 'addConnection',
1163 | source: 'IF',
1164 | target: 'FalseHandler',
1165 | branch: 'false' // Smart parameter for false branch
1166 | };
1167 |
1168 | const request: WorkflowDiffRequest = {
1169 | id: 'test-workflow',
1170 | operations: [addIF, addFalseHandler, connectWithBranch]
1171 | };
1172 |
1173 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1174 |
1175 | expect(result.success).toBe(true);
1176 |
1177 | // Should create connection on 'main' output, index 1 (false branch)
1178 | expect(result.workflow!.connections['IF']['main']).toBeDefined();
1179 | expect(result.workflow!.connections['IF']['main'][1]).toBeDefined();
1180 | expect(result.workflow!.connections['IF']['main'][1][0].node).toBe('FalseHandler');
1181 | });
1182 |
1183 | it('should use case parameter for Switch node connections', async () => {
1184 | // Add Switch node
1185 | const addSwitch: any = {
1186 | type: 'addNode',
1187 | node: {
1188 | name: 'Switch',
1189 | type: 'n8n-nodes-base.switch',
1190 | position: [400, 300]
1191 | }
1192 | };
1193 |
1194 | // Add handler nodes
1195 | const addCase0: any = {
1196 | type: 'addNode',
1197 | node: {
1198 | name: 'Case0Handler',
1199 | type: 'n8n-nodes-base.set',
1200 | position: [600, 200]
1201 | }
1202 | };
1203 |
1204 | const addCase1: any = {
1205 | type: 'addNode',
1206 | node: {
1207 | name: 'Case1Handler',
1208 | type: 'n8n-nodes-base.set',
1209 | position: [600, 300]
1210 | }
1211 | };
1212 |
1213 | const addCase2: any = {
1214 | type: 'addNode',
1215 | node: {
1216 | name: 'Case2Handler',
1217 | type: 'n8n-nodes-base.set',
1218 | position: [600, 400]
1219 | }
1220 | };
1221 |
1222 | // Connect using case parameter
1223 | const connectCase0: any = {
1224 | type: 'addConnection',
1225 | source: 'Switch',
1226 | target: 'Case0Handler',
1227 | case: 0 // Smart parameter instead of sourceIndex: 0
1228 | };
1229 |
1230 | const connectCase1: any = {
1231 | type: 'addConnection',
1232 | source: 'Switch',
1233 | target: 'Case1Handler',
1234 | case: 1 // Smart parameter instead of sourceIndex: 1
1235 | };
1236 |
1237 | const connectCase2: any = {
1238 | type: 'addConnection',
1239 | source: 'Switch',
1240 | target: 'Case2Handler',
1241 | case: 2 // Smart parameter instead of sourceIndex: 2
1242 | };
1243 |
1244 | const request: WorkflowDiffRequest = {
1245 | id: 'test-workflow',
1246 | operations: [addSwitch, addCase0, addCase1, addCase2, connectCase0, connectCase1, connectCase2]
1247 | };
1248 |
1249 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1250 |
1251 | expect(result.success).toBe(true);
1252 |
1253 | // All cases should be routed correctly
1254 | expect(result.workflow!.connections['Switch']['main'][0][0].node).toBe('Case0Handler');
1255 | expect(result.workflow!.connections['Switch']['main'][1][0].node).toBe('Case1Handler');
1256 | expect(result.workflow!.connections['Switch']['main'][2][0].node).toBe('Case2Handler');
1257 | });
1258 |
1259 | it('should use branch parameter with rewireConnection', async () => {
1260 | // Setup: Create IF node with true/false branches
1261 | const addIF: any = {
1262 | type: 'addNode',
1263 | node: {
1264 | name: 'IFRewire',
1265 | type: 'n8n-nodes-base.if',
1266 | position: [400, 300]
1267 | }
1268 | };
1269 |
1270 | const addSuccess: any = {
1271 | type: 'addNode',
1272 | node: {
1273 | name: 'SuccessHandler',
1274 | type: 'n8n-nodes-base.set',
1275 | position: [600, 200]
1276 | }
1277 | };
1278 |
1279 | const addNewSuccess: any = {
1280 | type: 'addNode',
1281 | node: {
1282 | name: 'NewSuccessHandler',
1283 | type: 'n8n-nodes-base.set',
1284 | position: [600, 250]
1285 | }
1286 | };
1287 |
1288 | // Initial connection
1289 | const initialConn: any = {
1290 | type: 'addConnection',
1291 | source: 'IFRewire',
1292 | target: 'SuccessHandler',
1293 | branch: 'true'
1294 | };
1295 |
1296 | // Rewire using branch parameter
1297 | const rewire: any = {
1298 | type: 'rewireConnection',
1299 | source: 'IFRewire',
1300 | from: 'SuccessHandler',
1301 | to: 'NewSuccessHandler',
1302 | branch: 'true' // Smart parameter
1303 | };
1304 |
1305 | const request: WorkflowDiffRequest = {
1306 | id: 'test-workflow',
1307 | operations: [addIF, addSuccess, addNewSuccess, initialConn, rewire]
1308 | };
1309 |
1310 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1311 |
1312 | expect(result.success).toBe(true);
1313 |
1314 | // Should rewire the true branch (main output, index 0)
1315 | expect(result.workflow!.connections['IFRewire']['main']).toBeDefined();
1316 | expect(result.workflow!.connections['IFRewire']['main'][0]).toBeDefined();
1317 | expect(result.workflow!.connections['IFRewire']['main'][0][0].node).toBe('NewSuccessHandler');
1318 | });
1319 |
1320 | it('should use case parameter with rewireConnection', async () => {
1321 | const addSwitch: any = {
1322 | type: 'addNode',
1323 | node: {
1324 | name: 'Switch',
1325 | type: 'n8n-nodes-base.switch',
1326 | position: [400, 300]
1327 | }
1328 | };
1329 |
1330 | const addCase1: any = {
1331 | type: 'addNode',
1332 | node: {
1333 | name: 'Case1Handler',
1334 | type: 'n8n-nodes-base.set',
1335 | position: [600, 300]
1336 | }
1337 | };
1338 |
1339 | const addNewCase1: any = {
1340 | type: 'addNode',
1341 | node: {
1342 | name: 'NewCase1Handler',
1343 | type: 'n8n-nodes-base.slack',
1344 | position: [600, 350]
1345 | }
1346 | };
1347 |
1348 | const initialConn: any = {
1349 | type: 'addConnection',
1350 | source: 'Switch',
1351 | target: 'Case1Handler',
1352 | case: 1
1353 | };
1354 |
1355 | const rewire: any = {
1356 | type: 'rewireConnection',
1357 | source: 'Switch',
1358 | from: 'Case1Handler',
1359 | to: 'NewCase1Handler',
1360 | case: 1 // Smart parameter
1361 | };
1362 |
1363 | const request: WorkflowDiffRequest = {
1364 | id: 'test-workflow',
1365 | operations: [addSwitch, addCase1, addNewCase1, initialConn, rewire]
1366 | };
1367 |
1368 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1369 |
1370 | expect(result.success).toBe(true);
1371 |
1372 | // Should rewire case 1
1373 | expect(result.workflow!.connections['Switch']['main'][1][0].node).toBe('NewCase1Handler');
1374 | });
1375 |
1376 | it('should not override explicit sourceOutput with branch parameter', async () => {
1377 | const addIF: any = {
1378 | type: 'addNode',
1379 | node: {
1380 | name: 'IFOverride',
1381 | type: 'n8n-nodes-base.if',
1382 | position: [400, 300]
1383 | }
1384 | };
1385 |
1386 | const addHandler: any = {
1387 | type: 'addNode',
1388 | node: {
1389 | name: 'OverrideHandler',
1390 | type: 'n8n-nodes-base.set',
1391 | position: [600, 300]
1392 | }
1393 | };
1394 |
1395 | // Both branch and sourceOutput provided - sourceOutput should win
1396 | const connectWithBoth: any = {
1397 | type: 'addConnection',
1398 | source: 'IFOverride',
1399 | target: 'OverrideHandler',
1400 | branch: 'true', // Smart parameter suggests 'true'
1401 | sourceOutput: 'false' // Explicit parameter should override
1402 | };
1403 |
1404 | const request: WorkflowDiffRequest = {
1405 | id: 'test-workflow',
1406 | operations: [addIF, addHandler, connectWithBoth]
1407 | };
1408 |
1409 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1410 |
1411 | expect(result.success).toBe(true);
1412 |
1413 | // Should use explicit sourceOutput ('false'), not smart branch parameter
1414 | // Note: explicit sourceOutput='false' creates connection on output named 'false'
1415 | // This is different from branch parameter which maps to sourceIndex
1416 | expect(result.workflow!.connections['IFOverride']['false']).toBeDefined();
1417 | expect(result.workflow!.connections['IFOverride']['false'][0][0].node).toBe('OverrideHandler');
1418 | expect(result.workflow!.connections['IFOverride']['main']).toBeUndefined();
1419 | });
1420 |
1421 | it('should not override explicit sourceIndex with case parameter', async () => {
1422 | const addSwitch: any = {
1423 | type: 'addNode',
1424 | node: {
1425 | name: 'Switch',
1426 | type: 'n8n-nodes-base.switch',
1427 | position: [400, 300]
1428 | }
1429 | };
1430 |
1431 | const addHandler: any = {
1432 | type: 'addNode',
1433 | node: {
1434 | name: 'Handler',
1435 | type: 'n8n-nodes-base.set',
1436 | position: [600, 300]
1437 | }
1438 | };
1439 |
1440 | // Both case and sourceIndex provided - sourceIndex should win
1441 | const connectWithBoth: any = {
1442 | type: 'addConnection',
1443 | source: 'Switch',
1444 | target: 'Handler',
1445 | case: 1, // Smart parameter suggests index 1
1446 | sourceIndex: 2 // Explicit parameter should override
1447 | };
1448 |
1449 | const request: WorkflowDiffRequest = {
1450 | id: 'test-workflow',
1451 | operations: [addSwitch, addHandler, connectWithBoth]
1452 | };
1453 |
1454 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1455 |
1456 | expect(result.success).toBe(true);
1457 |
1458 | // Should use explicit sourceIndex (2), not case (1)
1459 | expect(result.workflow!.connections['Switch']['main'][2]).toBeDefined();
1460 | expect(result.workflow!.connections['Switch']['main'][2][0].node).toBe('Handler');
1461 | expect(result.workflow!.connections['Switch']['main'][1]).toEqual([]);
1462 | });
1463 |
1464 | it('should warn when using sourceIndex with If node (issue #360)', async () => {
1465 | const addIF: any = {
1466 | type: 'addNode',
1467 | node: {
1468 | name: 'Check Condition',
1469 | type: 'n8n-nodes-base.if',
1470 | position: [400, 300]
1471 | }
1472 | };
1473 |
1474 | const addSuccess: any = {
1475 | type: 'addNode',
1476 | node: {
1477 | name: 'Success Handler',
1478 | type: 'n8n-nodes-base.set',
1479 | position: [600, 200]
1480 | }
1481 | };
1482 |
1483 | const addError: any = {
1484 | type: 'addNode',
1485 | node: {
1486 | name: 'Error Handler',
1487 | type: 'n8n-nodes-base.set',
1488 | position: [600, 400]
1489 | }
1490 | };
1491 |
1492 | // BAD: Using sourceIndex with If node (reproduces issue #360)
1493 | const connectSuccess: any = {
1494 | type: 'addConnection',
1495 | source: 'Check Condition',
1496 | target: 'Success Handler',
1497 | sourceIndex: 0 // Should use branch="true" instead
1498 | };
1499 |
1500 | const connectError: any = {
1501 | type: 'addConnection',
1502 | source: 'Check Condition',
1503 | target: 'Error Handler',
1504 | sourceIndex: 0 // Should use branch="false" instead - both will end up in main[0]!
1505 | };
1506 |
1507 | const request: WorkflowDiffRequest = {
1508 | id: 'test-workflow',
1509 | operations: [addIF, addSuccess, addError, connectSuccess, connectError]
1510 | };
1511 |
1512 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1513 |
1514 | expect(result.success).toBe(true);
1515 |
1516 | // Should produce warnings
1517 | expect(result.warnings).toBeDefined();
1518 | expect(result.warnings!.length).toBe(2);
1519 | expect(result.warnings![0].message).toContain('Consider using branch="true" or branch="false"');
1520 | expect(result.warnings![0].message).toContain('If node outputs: main[0]=TRUE branch, main[1]=FALSE branch');
1521 | expect(result.warnings![1].message).toContain('Consider using branch="true" or branch="false"');
1522 |
1523 | // Both connections end up in main[0] (the bug behavior)
1524 | expect(result.workflow!.connections['Check Condition']['main'][0].length).toBe(2);
1525 | expect(result.workflow!.connections['Check Condition']['main'][0][0].node).toBe('Success Handler');
1526 | expect(result.workflow!.connections['Check Condition']['main'][0][1].node).toBe('Error Handler');
1527 | });
1528 |
1529 | it('should warn when using sourceIndex with Switch node', async () => {
1530 | const addSwitch: any = {
1531 | type: 'addNode',
1532 | node: {
1533 | name: 'Switch',
1534 | type: 'n8n-nodes-base.switch',
1535 | position: [400, 300]
1536 | }
1537 | };
1538 |
1539 | const addHandler: any = {
1540 | type: 'addNode',
1541 | node: {
1542 | name: 'Handler',
1543 | type: 'n8n-nodes-base.set',
1544 | position: [600, 300]
1545 | }
1546 | };
1547 |
1548 | // BAD: Using sourceIndex with Switch node
1549 | const connect: any = {
1550 | type: 'addConnection',
1551 | source: 'Switch',
1552 | target: 'Handler',
1553 | sourceIndex: 1 // Should use case=1 instead
1554 | };
1555 |
1556 | const request: WorkflowDiffRequest = {
1557 | id: 'test-workflow',
1558 | operations: [addSwitch, addHandler, connect]
1559 | };
1560 |
1561 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1562 |
1563 | expect(result.success).toBe(true);
1564 |
1565 | // Should produce warning
1566 | expect(result.warnings).toBeDefined();
1567 | expect(result.warnings!.length).toBe(1);
1568 | expect(result.warnings![0].message).toContain('Consider using case=N for better clarity');
1569 | });
1570 | });
1571 |
1572 | describe('AddConnection with sourceIndex (Phase 0 Fix)', () => {
1573 | it('should add connection to correct sourceIndex', async () => {
1574 | // Add IF node
1575 | const addNodeOp: AddNodeOperation = {
1576 | type: 'addNode',
1577 | node: {
1578 | name: 'IF',
1579 | type: 'n8n-nodes-base.if',
1580 | position: [600, 300]
1581 | }
1582 | };
1583 |
1584 | // Add two different target nodes
1585 | const addNode1: AddNodeOperation = {
1586 | type: 'addNode',
1587 | node: {
1588 | name: 'SuccessHandler',
1589 | type: 'n8n-nodes-base.set',
1590 | position: [800, 200]
1591 | }
1592 | };
1593 |
1594 | const addNode2: AddNodeOperation = {
1595 | type: 'addNode',
1596 | node: {
1597 | name: 'ErrorHandler',
1598 | type: 'n8n-nodes-base.set',
1599 | position: [800, 400]
1600 | }
1601 | };
1602 |
1603 | // Connect to 'true' output at index 0
1604 | const addConnection1: AddConnectionOperation = {
1605 | type: 'addConnection',
1606 | source: 'IF',
1607 | target: 'SuccessHandler',
1608 | sourceOutput: 'true',
1609 | sourceIndex: 0
1610 | };
1611 |
1612 | // Connect to 'false' output at index 0
1613 | const addConnection2: AddConnectionOperation = {
1614 | type: 'addConnection',
1615 | source: 'IF',
1616 | target: 'ErrorHandler',
1617 | sourceOutput: 'false',
1618 | sourceIndex: 0
1619 | };
1620 |
1621 | const request: WorkflowDiffRequest = {
1622 | id: 'test-workflow',
1623 | operations: [addNodeOp, addNode1, addNode2, addConnection1, addConnection2]
1624 | };
1625 |
1626 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1627 |
1628 | expect(result.success).toBe(true);
1629 | // Verify connections are at correct indices
1630 | expect(result.workflow!.connections['IF']['true']).toBeDefined();
1631 | expect(result.workflow!.connections['IF']['true'][0]).toBeDefined();
1632 | expect(result.workflow!.connections['IF']['true'][0][0].node).toBe('SuccessHandler');
1633 |
1634 | expect(result.workflow!.connections['IF']['false']).toBeDefined();
1635 | expect(result.workflow!.connections['IF']['false'][0]).toBeDefined();
1636 | expect(result.workflow!.connections['IF']['false'][0][0].node).toBe('ErrorHandler');
1637 | });
1638 |
1639 | it('should support multiple connections at same sourceIndex (parallel execution)', async () => {
1640 | // Use a fresh workflow to avoid interference
1641 | const freshWorkflow = JSON.parse(JSON.stringify(baseWorkflow));
1642 |
1643 | // Add three target nodes
1644 | const addNode1: AddNodeOperation = {
1645 | type: 'addNode',
1646 | node: {
1647 | name: 'Processor1',
1648 | type: 'n8n-nodes-base.set',
1649 | position: [600, 200]
1650 | }
1651 | };
1652 |
1653 | const addNode2: AddNodeOperation = {
1654 | type: 'addNode',
1655 | node: {
1656 | name: 'Processor2',
1657 | type: 'n8n-nodes-base.set',
1658 | position: [600, 300]
1659 | }
1660 | };
1661 |
1662 | const addNode3: AddNodeOperation = {
1663 | type: 'addNode',
1664 | node: {
1665 | name: 'Processor3',
1666 | type: 'n8n-nodes-base.set',
1667 | position: [600, 400]
1668 | }
1669 | };
1670 |
1671 | // All connect from Webhook at sourceIndex 0 (parallel)
1672 | const addConnection1: AddConnectionOperation = {
1673 | type: 'addConnection',
1674 | source: 'Webhook',
1675 | target: 'Processor1',
1676 | sourceIndex: 0
1677 | };
1678 |
1679 | const addConnection2: AddConnectionOperation = {
1680 | type: 'addConnection',
1681 | source: 'Webhook',
1682 | target: 'Processor2',
1683 | sourceIndex: 0
1684 | };
1685 |
1686 | const addConnection3: AddConnectionOperation = {
1687 | type: 'addConnection',
1688 | source: 'Webhook',
1689 | target: 'Processor3',
1690 | sourceIndex: 0
1691 | };
1692 |
1693 | const request: WorkflowDiffRequest = {
1694 | id: 'test-workflow',
1695 | operations: [addNode1, addNode2, addNode3, addConnection1, addConnection2, addConnection3]
1696 | };
1697 |
1698 | const result = await diffEngine.applyDiff(freshWorkflow, request);
1699 |
1700 | expect(result.success).toBe(true);
1701 | // All three new processors plus the existing HTTP Request should be at index 0
1702 | // So we expect 4 total connections
1703 | const connectionsAtIndex0 = result.workflow!.connections['Webhook']['main'][0];
1704 | expect(connectionsAtIndex0.length).toBeGreaterThanOrEqual(3);
1705 | const targets = connectionsAtIndex0.map((c: any) => c.node);
1706 | expect(targets).toContain('Processor1');
1707 | expect(targets).toContain('Processor2');
1708 | expect(targets).toContain('Processor3');
1709 | });
1710 |
1711 | it('should support connections at different sourceIndices (Switch node pattern)', async () => {
1712 | // Add Switch node
1713 | const addSwitchNode: AddNodeOperation = {
1714 | type: 'addNode',
1715 | node: {
1716 | name: 'Switch',
1717 | type: 'n8n-nodes-base.switch',
1718 | position: [400, 300]
1719 | }
1720 | };
1721 |
1722 | // Add handlers for different cases
1723 | const addCase0: AddNodeOperation = {
1724 | type: 'addNode',
1725 | node: {
1726 | name: 'Case0Handler',
1727 | type: 'n8n-nodes-base.set',
1728 | position: [600, 200]
1729 | }
1730 | };
1731 |
1732 | const addCase1: AddNodeOperation = {
1733 | type: 'addNode',
1734 | node: {
1735 | name: 'Case1Handler',
1736 | type: 'n8n-nodes-base.set',
1737 | position: [600, 300]
1738 | }
1739 | };
1740 |
1741 | const addCase2: AddNodeOperation = {
1742 | type: 'addNode',
1743 | node: {
1744 | name: 'Case2Handler',
1745 | type: 'n8n-nodes-base.set',
1746 | position: [600, 400]
1747 | }
1748 | };
1749 |
1750 | // Connect to different sourceIndices
1751 | const conn0: AddConnectionOperation = {
1752 | type: 'addConnection',
1753 | source: 'Switch',
1754 | target: 'Case0Handler',
1755 | sourceIndex: 0
1756 | };
1757 |
1758 | const conn1: AddConnectionOperation = {
1759 | type: 'addConnection',
1760 | source: 'Switch',
1761 | target: 'Case1Handler',
1762 | sourceIndex: 1
1763 | };
1764 |
1765 | const conn2: AddConnectionOperation = {
1766 | type: 'addConnection',
1767 | source: 'Switch',
1768 | target: 'Case2Handler',
1769 | sourceIndex: 2
1770 | };
1771 |
1772 | const request: WorkflowDiffRequest = {
1773 | id: 'test-workflow',
1774 | operations: [addSwitchNode, addCase0, addCase1, addCase2, conn0, conn1, conn2]
1775 | };
1776 |
1777 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1778 |
1779 | expect(result.success).toBe(true);
1780 | // Verify each case routes to correct handler
1781 | expect(result.workflow!.connections['Switch']['main'][0][0].node).toBe('Case0Handler');
1782 | expect(result.workflow!.connections['Switch']['main'][1][0].node).toBe('Case1Handler');
1783 | expect(result.workflow!.connections['Switch']['main'][2][0].node).toBe('Case2Handler');
1784 | });
1785 |
1786 | it('should properly handle sourceIndex 0 as explicit value vs default', async () => {
1787 | // Use a fresh workflow
1788 | const freshWorkflow = JSON.parse(JSON.stringify(baseWorkflow));
1789 |
1790 | const addNode: AddNodeOperation = {
1791 | type: 'addNode',
1792 | node: {
1793 | name: 'TestNode',
1794 | type: 'n8n-nodes-base.set',
1795 | position: [600, 300]
1796 | }
1797 | };
1798 |
1799 | // Explicit sourceIndex: 0
1800 | const connection1: AddConnectionOperation = {
1801 | type: 'addConnection',
1802 | source: 'Webhook',
1803 | target: 'TestNode',
1804 | sourceIndex: 0
1805 | };
1806 |
1807 | const request: WorkflowDiffRequest = {
1808 | id: 'test-workflow',
1809 | operations: [addNode, connection1]
1810 | };
1811 |
1812 | const result = await diffEngine.applyDiff(freshWorkflow, request);
1813 |
1814 | expect(result.success).toBe(true);
1815 | expect(result.workflow!.connections['Webhook']['main'][0]).toBeDefined();
1816 | // TestNode should be in the connections (might not be first if HTTP Request already exists)
1817 | const targets = result.workflow!.connections['Webhook']['main'][0].map((c: any) => c.node);
1818 | expect(targets).toContain('TestNode');
1819 | });
1820 | });
1821 |
1822 | describe('UpdateSettings Operation', () => {
1823 | it('should update workflow settings', async () => {
1824 | const operation: UpdateSettingsOperation = {
1825 | type: 'updateSettings',
1826 | settings: {
1827 | executionOrder: 'v0',
1828 | timezone: 'America/New_York',
1829 | executionTimeout: 300
1830 | }
1831 | };
1832 |
1833 | const request: WorkflowDiffRequest = {
1834 | id: 'test-workflow',
1835 | operations: [operation]
1836 | };
1837 |
1838 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1839 |
1840 | expect(result.success).toBe(true);
1841 | expect(result.workflow!.settings!.executionOrder).toBe('v0');
1842 | expect(result.workflow!.settings!.timezone).toBe('America/New_York');
1843 | expect(result.workflow!.settings!.executionTimeout).toBe(300);
1844 | });
1845 |
1846 | it('should create settings object if not exists', async () => {
1847 | delete baseWorkflow.settings;
1848 |
1849 | const operation: UpdateSettingsOperation = {
1850 | type: 'updateSettings',
1851 | settings: {
1852 | saveManualExecutions: false
1853 | }
1854 | };
1855 |
1856 | const request: WorkflowDiffRequest = {
1857 | id: 'test-workflow',
1858 | operations: [operation]
1859 | };
1860 |
1861 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1862 |
1863 | expect(result.success).toBe(true);
1864 | expect(result.workflow!.settings).toBeDefined();
1865 | expect(result.workflow!.settings!.saveManualExecutions).toBe(false);
1866 | });
1867 | });
1868 |
1869 | describe('UpdateName Operation', () => {
1870 | it('should update workflow name', async () => {
1871 | const operation: UpdateNameOperation = {
1872 | type: 'updateName',
1873 | name: 'Updated Workflow Name'
1874 | };
1875 |
1876 | const request: WorkflowDiffRequest = {
1877 | id: 'test-workflow',
1878 | operations: [operation]
1879 | };
1880 |
1881 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1882 |
1883 | expect(result.success).toBe(true);
1884 | expect(result.workflow!.name).toBe('Updated Workflow Name');
1885 | });
1886 | });
1887 |
1888 | describe('Tag Operations', () => {
1889 | it('should add a new tag', async () => {
1890 | const operation: AddTagOperation = {
1891 | type: 'addTag',
1892 | tag: 'production'
1893 | };
1894 |
1895 | const request: WorkflowDiffRequest = {
1896 | id: 'test-workflow',
1897 | operations: [operation]
1898 | };
1899 |
1900 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1901 |
1902 | expect(result.success).toBe(true);
1903 | expect(result.workflow!.tags).toContain('production');
1904 | expect(result.workflow!.tags).toHaveLength(3);
1905 | });
1906 |
1907 | it('should not add duplicate tags', async () => {
1908 | const operation: AddTagOperation = {
1909 | type: 'addTag',
1910 | tag: 'test' // Already exists
1911 | };
1912 |
1913 | const request: WorkflowDiffRequest = {
1914 | id: 'test-workflow',
1915 | operations: [operation]
1916 | };
1917 |
1918 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1919 |
1920 | expect(result.success).toBe(true);
1921 | expect(result.workflow!.tags).toHaveLength(2); // No change
1922 | });
1923 |
1924 | it('should create tags array if not exists', async () => {
1925 | delete baseWorkflow.tags;
1926 |
1927 | const operation: AddTagOperation = {
1928 | type: 'addTag',
1929 | tag: 'new-tag'
1930 | };
1931 |
1932 | const request: WorkflowDiffRequest = {
1933 | id: 'test-workflow',
1934 | operations: [operation]
1935 | };
1936 |
1937 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1938 |
1939 | expect(result.success).toBe(true);
1940 | expect(result.workflow!.tags).toBeDefined();
1941 | expect(result.workflow!.tags).toEqual(['new-tag']);
1942 | });
1943 |
1944 | it('should remove an existing tag', async () => {
1945 | const operation: RemoveTagOperation = {
1946 | type: 'removeTag',
1947 | tag: 'test'
1948 | };
1949 |
1950 | const request: WorkflowDiffRequest = {
1951 | id: 'test-workflow',
1952 | operations: [operation]
1953 | };
1954 |
1955 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1956 |
1957 | expect(result.success).toBe(true);
1958 | expect(result.workflow!.tags).not.toContain('test');
1959 | expect(result.workflow!.tags).toHaveLength(1);
1960 | });
1961 |
1962 | it('should handle removing non-existent tag gracefully', async () => {
1963 | const operation: RemoveTagOperation = {
1964 | type: 'removeTag',
1965 | tag: 'non-existent'
1966 | };
1967 |
1968 | const request: WorkflowDiffRequest = {
1969 | id: 'test-workflow',
1970 | operations: [operation]
1971 | };
1972 |
1973 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1974 |
1975 | expect(result.success).toBe(true);
1976 | expect(result.workflow!.tags).toHaveLength(2); // No change
1977 | });
1978 | });
1979 |
1980 | describe('ValidateOnly Mode', () => {
1981 | it('should validate without applying changes', async () => {
1982 | const operation: UpdateNameOperation = {
1983 | type: 'updateName',
1984 | name: 'Validated But Not Applied'
1985 | };
1986 |
1987 | const request: WorkflowDiffRequest = {
1988 | id: 'test-workflow',
1989 | operations: [operation],
1990 | validateOnly: true
1991 | };
1992 |
1993 | const result = await diffEngine.applyDiff(baseWorkflow, request);
1994 |
1995 | expect(result.success).toBe(true);
1996 | expect(result.message).toContain('Validation successful');
1997 | expect(result.workflow).toBeUndefined();
1998 | });
1999 |
2000 | it('should return validation errors in validateOnly mode', async () => {
2001 | const operation: RemoveNodeOperation = {
2002 | type: 'removeNode',
2003 | nodeId: 'non-existent'
2004 | };
2005 |
2006 | const request: WorkflowDiffRequest = {
2007 | id: 'test-workflow',
2008 | operations: [operation],
2009 | validateOnly: true
2010 | };
2011 |
2012 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2013 |
2014 | expect(result.success).toBe(false);
2015 | expect(result.errors![0].message).toContain('Node not found');
2016 | });
2017 | });
2018 |
2019 | describe('Operation Ordering', () => {
2020 | it('should process node operations before connection operations', async () => {
2021 | // This tests the two-pass processing: nodes first, then connections
2022 | const operations = [
2023 | {
2024 | type: 'addConnection',
2025 | source: 'NewNode',
2026 | target: 'slack-1'
2027 | } as AddConnectionOperation,
2028 | {
2029 | type: 'addNode',
2030 | node: {
2031 | name: 'NewNode',
2032 | type: 'n8n-nodes-base.code',
2033 | position: [800, 300]
2034 | }
2035 | } as AddNodeOperation
2036 | ];
2037 |
2038 | const request: WorkflowDiffRequest = {
2039 | id: 'test-workflow',
2040 | operations
2041 | };
2042 |
2043 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2044 |
2045 | expect(result.success).toBe(true);
2046 | expect(result.workflow!.nodes).toHaveLength(4);
2047 | expect(result.workflow!.connections['NewNode']).toBeDefined();
2048 | });
2049 |
2050 | it('should handle dependent operations correctly', async () => {
2051 | const operations = [
2052 | {
2053 | type: 'removeNode',
2054 | nodeId: 'http-1'
2055 | } as RemoveNodeOperation,
2056 | {
2057 | type: 'addNode',
2058 | node: {
2059 | name: 'HTTP Request', // Reuse the same name
2060 | type: 'n8n-nodes-base.httpRequest',
2061 | position: [600, 300]
2062 | }
2063 | } as AddNodeOperation,
2064 | {
2065 | type: 'addConnection',
2066 | source: 'webhook-1',
2067 | target: 'HTTP Request'
2068 | } as AddConnectionOperation
2069 | ];
2070 |
2071 | const request: WorkflowDiffRequest = {
2072 | id: 'test-workflow',
2073 | operations
2074 | };
2075 |
2076 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2077 |
2078 | expect(result.success).toBe(true);
2079 | expect(result.workflow!.nodes).toHaveLength(3);
2080 | expect(result.workflow!.connections['Webhook'].main[0][0].node).toBe('HTTP Request');
2081 | });
2082 | });
2083 |
2084 | describe('Error Handling', () => {
2085 | it('should handle unknown operation type', async () => {
2086 | const operation = {
2087 | type: 'unknownOperation',
2088 | someData: 'test'
2089 | } as any;
2090 |
2091 | const request: WorkflowDiffRequest = {
2092 | id: 'test-workflow',
2093 | operations: [operation]
2094 | };
2095 |
2096 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2097 |
2098 | expect(result.success).toBe(false);
2099 | expect(result.errors![0].message).toContain('Unknown operation type');
2100 | });
2101 |
2102 | it('should stop on first validation error', async () => {
2103 | const operations = [
2104 | {
2105 | type: 'removeNode',
2106 | nodeId: 'non-existent'
2107 | } as RemoveNodeOperation,
2108 | {
2109 | type: 'updateName',
2110 | name: 'This should not be applied'
2111 | } as UpdateNameOperation
2112 | ];
2113 |
2114 | const request: WorkflowDiffRequest = {
2115 | id: 'test-workflow',
2116 | operations
2117 | };
2118 |
2119 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2120 |
2121 | expect(result.success).toBe(false);
2122 | expect(result.errors).toHaveLength(1);
2123 | expect(result.errors![0].operation).toBe(0);
2124 | });
2125 |
2126 | it('should return operation details in error', async () => {
2127 | const operation: RemoveNodeOperation = {
2128 | type: 'removeNode',
2129 | nodeId: 'non-existent',
2130 | description: 'Test remove operation'
2131 | };
2132 |
2133 | const request: WorkflowDiffRequest = {
2134 | id: 'test-workflow',
2135 | operations: [operation]
2136 | };
2137 |
2138 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2139 |
2140 | expect(result.success).toBe(false);
2141 | expect(result.errors![0].details).toEqual(operation);
2142 | });
2143 | });
2144 |
2145 | describe('Complex Scenarios', () => {
2146 | it('should handle multiple operations of different types', async () => {
2147 | const operations = [
2148 | {
2149 | type: 'updateName',
2150 | name: 'Complex Workflow'
2151 | } as UpdateNameOperation,
2152 | {
2153 | type: 'addNode',
2154 | node: {
2155 | name: 'Filter',
2156 | type: 'n8n-nodes-base.filter',
2157 | position: [800, 200]
2158 | }
2159 | } as AddNodeOperation,
2160 | {
2161 | type: 'removeConnection',
2162 | source: 'HTTP Request', // Use node name
2163 | target: 'Slack' // Use node name
2164 | } as RemoveConnectionOperation,
2165 | {
2166 | type: 'addConnection',
2167 | source: 'HTTP Request', // Use node name
2168 | target: 'Filter'
2169 | } as AddConnectionOperation,
2170 | {
2171 | type: 'addConnection',
2172 | source: 'Filter',
2173 | target: 'Slack' // Use node name
2174 | } as AddConnectionOperation
2175 | ];
2176 |
2177 | const request: WorkflowDiffRequest = {
2178 | id: 'test-workflow',
2179 | operations
2180 | };
2181 |
2182 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2183 |
2184 | expect(result.success).toBe(true);
2185 | expect(result.workflow!.name).toBe('Complex Workflow');
2186 | expect(result.workflow!.nodes).toHaveLength(4);
2187 | expect(result.workflow!.connections['HTTP Request'].main[0][0].node).toBe('Filter');
2188 | expect(result.workflow!.connections['Filter'].main[0][0].node).toBe('Slack');
2189 | expect(result.operationsApplied).toBe(5);
2190 | });
2191 |
2192 | it('should preserve workflow immutability', async () => {
2193 | const originalNodes = [...baseWorkflow.nodes];
2194 | const originalConnections = JSON.stringify(baseWorkflow.connections);
2195 |
2196 | const operation: UpdateNameOperation = {
2197 | type: 'updateName',
2198 | name: 'Modified'
2199 | };
2200 |
2201 | const request: WorkflowDiffRequest = {
2202 | id: 'test-workflow',
2203 | operations: [operation]
2204 | };
2205 |
2206 | await diffEngine.applyDiff(baseWorkflow, request);
2207 |
2208 | // Original workflow should remain unchanged
2209 | expect(baseWorkflow.name).toBe('Test Workflow');
2210 | expect(baseWorkflow.nodes).toEqual(originalNodes);
2211 | expect(JSON.stringify(baseWorkflow.connections)).toBe(originalConnections);
2212 | });
2213 |
2214 | it('should handle node ID as name fallback', async () => {
2215 | // Test the findNode helper's fallback behavior
2216 | const operation: UpdateNodeOperation = {
2217 | type: 'updateNode',
2218 | nodeId: 'Webhook', // Using name as ID
2219 | updates: {
2220 | 'parameters.path': 'new-webhook-path'
2221 | }
2222 | };
2223 |
2224 | const request: WorkflowDiffRequest = {
2225 | id: 'test-workflow',
2226 | operations: [operation]
2227 | };
2228 |
2229 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2230 |
2231 | expect(result.success).toBe(true);
2232 | const updatedNode = result.workflow!.nodes.find((n: any) => n.name === 'Webhook');
2233 | expect(updatedNode!.parameters.path).toBe('new-webhook-path');
2234 | });
2235 | });
2236 |
2237 | describe('Success Messages', () => {
2238 | it('should provide informative success message', async () => {
2239 | const operations = [
2240 | {
2241 | type: 'addNode',
2242 | node: {
2243 | name: 'Node1',
2244 | type: 'n8n-nodes-base.code',
2245 | position: [100, 100]
2246 | }
2247 | } as AddNodeOperation,
2248 | {
2249 | type: 'updateSettings',
2250 | settings: { timezone: 'UTC' }
2251 | } as UpdateSettingsOperation,
2252 | {
2253 | type: 'addTag',
2254 | tag: 'v2'
2255 | } as AddTagOperation
2256 | ];
2257 |
2258 | const request: WorkflowDiffRequest = {
2259 | id: 'test-workflow',
2260 | operations
2261 | };
2262 |
2263 | const result = await diffEngine.applyDiff(baseWorkflow, request);
2264 |
2265 | expect(result.success).toBe(true);
2266 | expect(result.message).toContain('Successfully applied 3 operations');
2267 | expect(result.message).toContain('1 node ops');
2268 | expect(result.message).toContain('2 other ops');
2269 | });
2270 | });
2271 |
2272 | describe('New Features - v2.14.4', () => {
2273 | describe('cleanStaleConnections operation', () => {
2274 | it('should remove connections referencing non-existent nodes', async () => {
2275 | // Create a workflow with a stale connection
2276 | const workflow = builder.build() as Workflow;
2277 |
2278 | // Add a connection to a non-existent node manually
2279 | if (!workflow.connections['Webhook']) {
2280 | workflow.connections['Webhook'] = {};
2281 | }
2282 | workflow.connections['Webhook']['main'] = [[
2283 | { node: 'HTTP Request', type: 'main', index: 0 },
2284 | { node: 'NonExistentNode', type: 'main', index: 0 }
2285 | ]];
2286 |
2287 | const operations: CleanStaleConnectionsOperation[] = [{
2288 | type: 'cleanStaleConnections'
2289 | }];
2290 |
2291 | const request: WorkflowDiffRequest = {
2292 | id: 'test-workflow',
2293 | operations
2294 | };
2295 |
2296 | const result = await diffEngine.applyDiff(workflow, request);
2297 |
2298 | expect(result.success).toBe(true);
2299 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(1);
2300 | expect(result.workflow.connections['Webhook']['main'][0][0].node).toBe('HTTP Request');
2301 | });
2302 |
2303 | it('should remove entire source connection if source node does not exist', async () => {
2304 | const workflow = builder.build() as Workflow;
2305 |
2306 | // Add connections from non-existent node
2307 | workflow.connections['GhostNode'] = {
2308 | 'main': [[
2309 | { node: 'HTTP Request', type: 'main', index: 0 }
2310 | ]]
2311 | };
2312 |
2313 | const operations: CleanStaleConnectionsOperation[] = [{
2314 | type: 'cleanStaleConnections'
2315 | }];
2316 |
2317 | const request: WorkflowDiffRequest = {
2318 | id: 'test-workflow',
2319 | operations
2320 | };
2321 |
2322 | const result = await diffEngine.applyDiff(workflow, request);
2323 |
2324 | expect(result.success).toBe(true);
2325 | expect(result.workflow.connections['GhostNode']).toBeUndefined();
2326 | });
2327 |
2328 | it('should support dryRun mode', async () => {
2329 | const workflow = builder.build() as Workflow;
2330 |
2331 | // Add a stale connection
2332 | if (!workflow.connections['Webhook']) {
2333 | workflow.connections['Webhook'] = {};
2334 | }
2335 | workflow.connections['Webhook']['main'] = [[
2336 | { node: 'HTTP Request', type: 'main', index: 0 },
2337 | { node: 'NonExistentNode', type: 'main', index: 0 }
2338 | ]];
2339 |
2340 | const operations: CleanStaleConnectionsOperation[] = [{
2341 | type: 'cleanStaleConnections',
2342 | dryRun: true
2343 | }];
2344 |
2345 | const request: WorkflowDiffRequest = {
2346 | id: 'test-workflow',
2347 | operations
2348 | };
2349 |
2350 | const result = await diffEngine.applyDiff(workflow, request);
2351 |
2352 | expect(result.success).toBe(true);
2353 | // In dryRun, stale connection should still be present (not actually removed)
2354 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(2);
2355 | });
2356 | });
2357 |
2358 | describe('replaceConnections operation', () => {
2359 | it('should replace entire connections object', async () => {
2360 | const workflow = builder.build() as Workflow;
2361 |
2362 | const newConnections = {
2363 | 'Webhook': {
2364 | 'main': [[
2365 | { node: 'Slack', type: 'main', index: 0 }
2366 | ]]
2367 | }
2368 | };
2369 |
2370 | const operations: ReplaceConnectionsOperation[] = [{
2371 | type: 'replaceConnections',
2372 | connections: newConnections
2373 | }];
2374 |
2375 | const request: WorkflowDiffRequest = {
2376 | id: 'test-workflow',
2377 | operations
2378 | };
2379 |
2380 | const result = await diffEngine.applyDiff(workflow, request);
2381 |
2382 | expect(result.success).toBe(true);
2383 | expect(result.workflow.connections).toEqual(newConnections);
2384 | expect(result.workflow.connections['HTTP Request']).toBeUndefined();
2385 | });
2386 |
2387 | it('should fail if referenced nodes do not exist', async () => {
2388 | const workflow = builder.build() as Workflow;
2389 |
2390 | const newConnections = {
2391 | 'Webhook': {
2392 | 'main': [[
2393 | { node: 'NonExistentNode', type: 'main', index: 0 }
2394 | ]]
2395 | }
2396 | };
2397 |
2398 | const operations: ReplaceConnectionsOperation[] = [{
2399 | type: 'replaceConnections',
2400 | connections: newConnections
2401 | }];
2402 |
2403 | const request: WorkflowDiffRequest = {
2404 | id: 'test-workflow',
2405 | operations
2406 | };
2407 |
2408 | const result = await diffEngine.applyDiff(workflow, request);
2409 |
2410 | expect(result.success).toBe(false);
2411 | expect(result.errors).toBeDefined();
2412 | expect(result.errors![0].message).toContain('Target node not found');
2413 | });
2414 | });
2415 |
2416 | describe('removeConnection with ignoreErrors flag', () => {
2417 | it('should succeed when connection does not exist if ignoreErrors is true', async () => {
2418 | const workflow = builder.build() as Workflow;
2419 |
2420 | const operations: RemoveConnectionOperation[] = [{
2421 | type: 'removeConnection',
2422 | source: 'Webhook',
2423 | target: 'NonExistentNode',
2424 | ignoreErrors: true
2425 | }];
2426 |
2427 | const request: WorkflowDiffRequest = {
2428 | id: 'test-workflow',
2429 | operations
2430 | };
2431 |
2432 | const result = await diffEngine.applyDiff(workflow, request);
2433 |
2434 | expect(result.success).toBe(true);
2435 | });
2436 |
2437 | it('should fail when connection does not exist if ignoreErrors is false', async () => {
2438 | const workflow = builder.build() as Workflow;
2439 |
2440 | const operations: RemoveConnectionOperation[] = [{
2441 | type: 'removeConnection',
2442 | source: 'Webhook',
2443 | target: 'NonExistentNode',
2444 | ignoreErrors: false
2445 | }];
2446 |
2447 | const request: WorkflowDiffRequest = {
2448 | id: 'test-workflow',
2449 | operations
2450 | };
2451 |
2452 | const result = await diffEngine.applyDiff(workflow, request);
2453 |
2454 | expect(result.success).toBe(false);
2455 | expect(result.errors).toBeDefined();
2456 | });
2457 |
2458 | it('should default to atomic behavior when ignoreErrors is not specified', async () => {
2459 | const workflow = builder.build() as Workflow;
2460 |
2461 | const operations: RemoveConnectionOperation[] = [{
2462 | type: 'removeConnection',
2463 | source: 'Webhook',
2464 | target: 'NonExistentNode'
2465 | }];
2466 |
2467 | const request: WorkflowDiffRequest = {
2468 | id: 'test-workflow',
2469 | operations
2470 | };
2471 |
2472 | const result = await diffEngine.applyDiff(workflow, request);
2473 |
2474 | expect(result.success).toBe(false);
2475 | expect(result.errors).toBeDefined();
2476 | });
2477 | });
2478 |
2479 | describe('continueOnError mode', () => {
2480 | it('should apply valid operations and report failed ones', async () => {
2481 | const workflow = builder.build() as Workflow;
2482 |
2483 | const operations: WorkflowDiffOperation[] = [
2484 | {
2485 | type: 'updateName',
2486 | name: 'New Workflow Name'
2487 | } as UpdateNameOperation,
2488 | {
2489 | type: 'removeConnection',
2490 | source: 'Webhook',
2491 | target: 'NonExistentNode'
2492 | } as RemoveConnectionOperation,
2493 | {
2494 | type: 'addTag',
2495 | tag: 'production'
2496 | } as AddTagOperation
2497 | ];
2498 |
2499 | const request: WorkflowDiffRequest = {
2500 | id: 'test-workflow',
2501 | operations,
2502 | continueOnError: true
2503 | };
2504 |
2505 | const result = await diffEngine.applyDiff(workflow, request);
2506 |
2507 | expect(result.success).toBe(true);
2508 | expect(result.applied).toEqual([0, 2]); // Operations 0 and 2 succeeded
2509 | expect(result.failed).toEqual([1]); // Operation 1 failed
2510 | expect(result.errors).toHaveLength(1);
2511 | expect(result.workflow.name).toBe('New Workflow Name');
2512 | expect(result.workflow.tags).toContain('production');
2513 | });
2514 |
2515 | it('should return success false if all operations fail in continueOnError mode', async () => {
2516 | const workflow = builder.build() as Workflow;
2517 |
2518 | const operations: WorkflowDiffOperation[] = [
2519 | {
2520 | type: 'removeConnection',
2521 | source: 'Webhook',
2522 | target: 'Node1'
2523 | } as RemoveConnectionOperation,
2524 | {
2525 | type: 'removeConnection',
2526 | source: 'Webhook',
2527 | target: 'Node2'
2528 | } as RemoveConnectionOperation
2529 | ];
2530 |
2531 | const request: WorkflowDiffRequest = {
2532 | id: 'test-workflow',
2533 | operations,
2534 | continueOnError: true
2535 | };
2536 |
2537 | const result = await diffEngine.applyDiff(workflow, request);
2538 |
2539 | expect(result.success).toBe(false);
2540 | expect(result.applied).toHaveLength(0);
2541 | expect(result.failed).toEqual([0, 1]);
2542 | });
2543 |
2544 | it('should use atomic mode by default when continueOnError is not specified', async () => {
2545 | const workflow = builder.build() as Workflow;
2546 |
2547 | const operations: WorkflowDiffOperation[] = [
2548 | {
2549 | type: 'updateName',
2550 | name: 'New Name'
2551 | } as UpdateNameOperation,
2552 | {
2553 | type: 'removeConnection',
2554 | source: 'Webhook',
2555 | target: 'NonExistent'
2556 | } as RemoveConnectionOperation
2557 | ];
2558 |
2559 | const request: WorkflowDiffRequest = {
2560 | id: 'test-workflow',
2561 | operations
2562 | };
2563 |
2564 | const result = await diffEngine.applyDiff(workflow, request);
2565 |
2566 | expect(result.success).toBe(false);
2567 | expect(result.applied).toBeUndefined();
2568 | expect(result.failed).toBeUndefined();
2569 | // Name should not have been updated due to atomic behavior
2570 | expect(result.workflow).toBeUndefined();
2571 | });
2572 | });
2573 |
2574 | describe('Backwards compatibility', () => {
2575 | it('should maintain existing behavior for all previous operation types', async () => {
2576 | const workflow = builder.build() as Workflow;
2577 |
2578 | const operations: WorkflowDiffOperation[] = [
2579 | { type: 'updateName', name: 'Test' } as UpdateNameOperation,
2580 | { type: 'addTag', tag: 'test' } as AddTagOperation,
2581 | { type: 'removeTag', tag: 'automation' } as RemoveTagOperation,
2582 | { type: 'updateSettings', settings: { timezone: 'UTC' } } as UpdateSettingsOperation
2583 | ];
2584 |
2585 | const request: WorkflowDiffRequest = {
2586 | id: 'test-workflow',
2587 | operations
2588 | };
2589 |
2590 | const result = await diffEngine.applyDiff(workflow, request);
2591 |
2592 | expect(result.success).toBe(true);
2593 | expect(result.operationsApplied).toBe(4);
2594 | });
2595 | });
2596 | });
2597 |
2598 | describe('v2.14.4 Coverage Improvements', () => {
2599 | describe('cleanStaleConnections - Advanced Scenarios', () => {
2600 | it('should clean up multiple stale connections across different output types', async () => {
2601 | const workflow = builder.build() as Workflow;
2602 |
2603 | // Add an IF node with multiple outputs
2604 | workflow.nodes.push({
2605 | id: 'if-1',
2606 | name: 'IF',
2607 | type: 'n8n-nodes-base.if',
2608 | typeVersion: 1,
2609 | position: [600, 400],
2610 | parameters: {}
2611 | });
2612 |
2613 | // Add connections with both valid and stale targets on different outputs
2614 | workflow.connections['IF'] = {
2615 | 'true': [[
2616 | { node: 'Slack', type: 'main', index: 0 },
2617 | { node: 'StaleNode1', type: 'main', index: 0 }
2618 | ]],
2619 | 'false': [[
2620 | { node: 'HTTP Request', type: 'main', index: 0 },
2621 | { node: 'StaleNode2', type: 'main', index: 0 }
2622 | ]]
2623 | };
2624 |
2625 | const operations: CleanStaleConnectionsOperation[] = [{
2626 | type: 'cleanStaleConnections'
2627 | }];
2628 |
2629 | const request: WorkflowDiffRequest = {
2630 | id: 'test-workflow',
2631 | operations
2632 | };
2633 |
2634 | const result = await diffEngine.applyDiff(workflow, request);
2635 |
2636 | expect(result.success).toBe(true);
2637 | expect(result.workflow.connections['IF']['true'][0]).toHaveLength(1);
2638 | expect(result.workflow.connections['IF']['true'][0][0].node).toBe('Slack');
2639 | expect(result.workflow.connections['IF']['false'][0]).toHaveLength(1);
2640 | expect(result.workflow.connections['IF']['false'][0][0].node).toBe('HTTP Request');
2641 | });
2642 |
2643 | it('should remove empty output types after cleaning stale connections', async () => {
2644 | const workflow = builder.build() as Workflow;
2645 |
2646 | // Add node with connections
2647 | workflow.nodes.push({
2648 | id: 'if-1',
2649 | name: 'IF',
2650 | type: 'n8n-nodes-base.if',
2651 | typeVersion: 1,
2652 | position: [600, 400],
2653 | parameters: {}
2654 | });
2655 |
2656 | // Add connections where all targets in one output are stale
2657 | workflow.connections['IF'] = {
2658 | 'true': [[
2659 | { node: 'StaleNode1', type: 'main', index: 0 },
2660 | { node: 'StaleNode2', type: 'main', index: 0 }
2661 | ]],
2662 | 'false': [[
2663 | { node: 'Slack', type: 'main', index: 0 }
2664 | ]]
2665 | };
2666 |
2667 | const operations: CleanStaleConnectionsOperation[] = [{
2668 | type: 'cleanStaleConnections'
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['IF']['true']).toBeUndefined();
2680 | expect(result.workflow.connections['IF']['false']).toBeDefined();
2681 | expect(result.workflow.connections['IF']['false'][0][0].node).toBe('Slack');
2682 | });
2683 |
2684 | it('should clean up entire node connections when all outputs become empty', async () => {
2685 | const workflow = builder.build() as Workflow;
2686 |
2687 | // Add node
2688 | workflow.nodes.push({
2689 | id: 'if-1',
2690 | name: 'IF',
2691 | type: 'n8n-nodes-base.if',
2692 | typeVersion: 1,
2693 | position: [600, 400],
2694 | parameters: {}
2695 | });
2696 |
2697 | // Add connections where ALL targets are stale
2698 | workflow.connections['IF'] = {
2699 | 'true': [[
2700 | { node: 'StaleNode1', type: 'main', index: 0 }
2701 | ]],
2702 | 'false': [[
2703 | { node: 'StaleNode2', type: 'main', index: 0 }
2704 | ]]
2705 | };
2706 |
2707 | const operations: CleanStaleConnectionsOperation[] = [{
2708 | type: 'cleanStaleConnections'
2709 | }];
2710 |
2711 | const request: WorkflowDiffRequest = {
2712 | id: 'test-workflow',
2713 | operations
2714 | };
2715 |
2716 | const result = await diffEngine.applyDiff(workflow, request);
2717 |
2718 | expect(result.success).toBe(true);
2719 | expect(result.workflow.connections['IF']).toBeUndefined();
2720 | });
2721 |
2722 | it('should handle dryRun with multiple stale connections', async () => {
2723 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
2724 |
2725 | // Add stale connections from both valid and invalid source nodes
2726 | workflow.connections['GhostNode'] = {
2727 | 'main': [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
2728 | };
2729 |
2730 | if (!workflow.connections['Webhook']) {
2731 | workflow.connections['Webhook'] = {};
2732 | }
2733 | workflow.connections['Webhook']['main'] = [[
2734 | { node: 'HTTP Request', type: 'main', index: 0 },
2735 | { node: 'StaleNode1', type: 'main', index: 0 },
2736 | { node: 'StaleNode2', type: 'main', index: 0 }
2737 | ]];
2738 |
2739 | const originalConnections = JSON.parse(JSON.stringify(workflow.connections));
2740 |
2741 | const operations: CleanStaleConnectionsOperation[] = [{
2742 | type: 'cleanStaleConnections',
2743 | dryRun: true
2744 | }];
2745 |
2746 | const request: WorkflowDiffRequest = {
2747 | id: 'test-workflow',
2748 | operations
2749 | };
2750 |
2751 | const result = await diffEngine.applyDiff(workflow, request);
2752 |
2753 | expect(result.success).toBe(true);
2754 | // Connections should remain unchanged in dryRun
2755 | expect(JSON.stringify(result.workflow.connections)).toBe(JSON.stringify(originalConnections));
2756 | });
2757 |
2758 | it('should handle workflow with no stale connections', async () => {
2759 | // Use baseWorkflow which has name-based connections
2760 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
2761 | const originalConnectionsCount = Object.keys(workflow.connections).length;
2762 |
2763 | const operations: CleanStaleConnectionsOperation[] = [{
2764 | type: 'cleanStaleConnections'
2765 | }];
2766 |
2767 | const request: WorkflowDiffRequest = {
2768 | id: 'test-workflow',
2769 | operations
2770 | };
2771 |
2772 | const result = await diffEngine.applyDiff(workflow, request);
2773 |
2774 | expect(result.success).toBe(true);
2775 | // Connections should remain unchanged (no stale connections to remove)
2776 | // Verify by checking connection count
2777 | expect(Object.keys(result.workflow.connections).length).toBe(originalConnectionsCount);
2778 | expect(result.workflow.connections['Webhook']).toBeDefined();
2779 | expect(result.workflow.connections['HTTP Request']).toBeDefined();
2780 | });
2781 | });
2782 |
2783 | describe('replaceConnections - Advanced Scenarios', () => {
2784 | it('should fail validation when source node does not exist', async () => {
2785 | const workflow = builder.build() as Workflow;
2786 |
2787 | const newConnections = {
2788 | 'NonExistentSource': {
2789 | 'main': [[
2790 | { node: 'Slack', type: 'main', index: 0 }
2791 | ]]
2792 | }
2793 | };
2794 |
2795 | const operations: ReplaceConnectionsOperation[] = [{
2796 | type: 'replaceConnections',
2797 | connections: newConnections
2798 | }];
2799 |
2800 | const request: WorkflowDiffRequest = {
2801 | id: 'test-workflow',
2802 | operations
2803 | };
2804 |
2805 | const result = await diffEngine.applyDiff(workflow, request);
2806 |
2807 | expect(result.success).toBe(false);
2808 | expect(result.errors).toBeDefined();
2809 | expect(result.errors![0].message).toContain('Source node not found');
2810 | });
2811 |
2812 | it('should successfully replace with empty connections object', async () => {
2813 | const workflow = builder.build() as Workflow;
2814 |
2815 | const operations: ReplaceConnectionsOperation[] = [{
2816 | type: 'replaceConnections',
2817 | connections: {}
2818 | }];
2819 |
2820 | const request: WorkflowDiffRequest = {
2821 | id: 'test-workflow',
2822 | operations
2823 | };
2824 |
2825 | const result = await diffEngine.applyDiff(workflow, request);
2826 |
2827 | expect(result.success).toBe(true);
2828 | expect(result.workflow.connections).toEqual({});
2829 | });
2830 |
2831 | it('should handle complex connection structures with multiple outputs', async () => {
2832 | const workflow = builder.build() as Workflow;
2833 |
2834 | // Add IF node
2835 | workflow.nodes.push({
2836 | id: 'if-1',
2837 | name: 'IF',
2838 | type: 'n8n-nodes-base.if',
2839 | typeVersion: 1,
2840 | position: [600, 400],
2841 | parameters: {}
2842 | });
2843 |
2844 | const newConnections = {
2845 | 'Webhook': {
2846 | 'main': [[
2847 | { node: 'IF', type: 'main', index: 0 }
2848 | ]]
2849 | },
2850 | 'IF': {
2851 | 'true': [[
2852 | { node: 'Slack', type: 'main', index: 0 }
2853 | ]],
2854 | 'false': [[
2855 | { node: 'HTTP Request', type: 'main', index: 0 }
2856 | ]]
2857 | }
2858 | };
2859 |
2860 | const operations: ReplaceConnectionsOperation[] = [{
2861 | type: 'replaceConnections',
2862 | connections: newConnections
2863 | }];
2864 |
2865 | const request: WorkflowDiffRequest = {
2866 | id: 'test-workflow',
2867 | operations
2868 | };
2869 |
2870 | const result = await diffEngine.applyDiff(workflow, request);
2871 |
2872 | expect(result.success).toBe(true);
2873 | expect(result.workflow.connections).toEqual(newConnections);
2874 | });
2875 | });
2876 |
2877 | describe('removeConnection with ignoreErrors - Advanced Scenarios', () => {
2878 | it('should succeed when source node does not exist with ignoreErrors', async () => {
2879 | const workflow = builder.build() as Workflow;
2880 |
2881 | const operations: RemoveConnectionOperation[] = [{
2882 | type: 'removeConnection',
2883 | source: 'NonExistentSource',
2884 | target: 'Slack',
2885 | ignoreErrors: true
2886 | }];
2887 |
2888 | const request: WorkflowDiffRequest = {
2889 | id: 'test-workflow',
2890 | operations
2891 | };
2892 |
2893 | const result = await diffEngine.applyDiff(workflow, request);
2894 |
2895 | expect(result.success).toBe(true);
2896 | // Workflow should remain unchanged (verify by checking node count)
2897 | expect(Object.keys(result.workflow.connections).length).toBe(Object.keys(baseWorkflow.connections).length);
2898 | });
2899 |
2900 | it('should succeed when both source and target nodes do not exist with ignoreErrors', async () => {
2901 | const workflow = builder.build() as Workflow;
2902 |
2903 | const operations: RemoveConnectionOperation[] = [{
2904 | type: 'removeConnection',
2905 | source: 'NonExistentSource',
2906 | target: 'NonExistentTarget',
2907 | ignoreErrors: true
2908 | }];
2909 |
2910 | const request: WorkflowDiffRequest = {
2911 | id: 'test-workflow',
2912 | operations
2913 | };
2914 |
2915 | const result = await diffEngine.applyDiff(workflow, request);
2916 |
2917 | expect(result.success).toBe(true);
2918 | });
2919 |
2920 | it('should succeed when connection exists but target node does not with ignoreErrors', async () => {
2921 | const workflow = builder.build() as Workflow;
2922 |
2923 | // This is an edge case where connection references a valid node but we're trying to remove to non-existent
2924 | const operations: RemoveConnectionOperation[] = [{
2925 | type: 'removeConnection',
2926 | source: 'Webhook',
2927 | target: 'NonExistentTarget',
2928 | ignoreErrors: true
2929 | }];
2930 |
2931 | const request: WorkflowDiffRequest = {
2932 | id: 'test-workflow',
2933 | operations
2934 | };
2935 |
2936 | const result = await diffEngine.applyDiff(workflow, request);
2937 |
2938 | expect(result.success).toBe(true);
2939 | });
2940 |
2941 | it('should fail when source node does not exist without ignoreErrors', async () => {
2942 | const workflow = builder.build() as Workflow;
2943 |
2944 | const operations: RemoveConnectionOperation[] = [{
2945 | type: 'removeConnection',
2946 | source: 'NonExistentSource',
2947 | target: 'Slack',
2948 | ignoreErrors: false
2949 | }];
2950 |
2951 | const request: WorkflowDiffRequest = {
2952 | id: 'test-workflow',
2953 | operations
2954 | };
2955 |
2956 | const result = await diffEngine.applyDiff(workflow, request);
2957 |
2958 | expect(result.success).toBe(false);
2959 | expect(result.errors![0].message).toContain('Source node not found');
2960 | });
2961 | });
2962 |
2963 | describe('continueOnError - Advanced Scenarios', () => {
2964 | it('should catch runtime errors during operation application', async () => {
2965 | const workflow = builder.build() as Workflow;
2966 |
2967 | // Create an operation that will pass validation but fail during application
2968 | // This is simulated by causing an error in the apply phase
2969 | const operations: WorkflowDiffOperation[] = [
2970 | {
2971 | type: 'updateName',
2972 | name: 'Valid Operation'
2973 | } as UpdateNameOperation,
2974 | {
2975 | type: 'updateNode',
2976 | nodeId: 'webhook-1',
2977 | updates: {
2978 | // This will pass validation but could fail in complex scenarios
2979 | 'parameters.invalidDeepPath.nested.value': 'test'
2980 | }
2981 | } as UpdateNodeOperation,
2982 | {
2983 | type: 'addTag',
2984 | tag: 'another-valid'
2985 | } as AddTagOperation
2986 | ];
2987 |
2988 | const request: WorkflowDiffRequest = {
2989 | id: 'test-workflow',
2990 | operations,
2991 | continueOnError: true
2992 | };
2993 |
2994 | const result = await diffEngine.applyDiff(workflow, request);
2995 |
2996 | // All operations should succeed in this case (no runtime errors expected)
2997 | expect(result.success).toBe(true);
2998 | expect(result.applied).toBeDefined();
2999 | expect(result.applied!.length).toBeGreaterThan(0);
3000 | });
3001 |
3002 | it('should handle mixed validation and runtime errors', async () => {
3003 | const workflow = builder.build() as Workflow;
3004 |
3005 | const operations: WorkflowDiffOperation[] = [
3006 | {
3007 | type: 'updateName',
3008 | name: 'Operation 0'
3009 | } as UpdateNameOperation,
3010 | {
3011 | type: 'removeNode',
3012 | nodeId: 'non-existent-1'
3013 | } as RemoveNodeOperation,
3014 | {
3015 | type: 'addTag',
3016 | tag: 'tag1'
3017 | } as AddTagOperation,
3018 | {
3019 | type: 'removeConnection',
3020 | source: 'Webhook',
3021 | target: 'NonExistent'
3022 | } as RemoveConnectionOperation,
3023 | {
3024 | type: 'addTag',
3025 | tag: 'tag2'
3026 | } as AddTagOperation
3027 | ];
3028 |
3029 | const request: WorkflowDiffRequest = {
3030 | id: 'test-workflow',
3031 | operations,
3032 | continueOnError: true
3033 | };
3034 |
3035 | const result = await diffEngine.applyDiff(workflow, request);
3036 |
3037 | expect(result.success).toBe(true);
3038 | expect(result.applied).toContain(0); // updateName
3039 | expect(result.applied).toContain(2); // first addTag
3040 | expect(result.applied).toContain(4); // second addTag
3041 | expect(result.failed).toContain(1); // removeNode
3042 | expect(result.failed).toContain(3); // removeConnection
3043 | expect(result.errors).toHaveLength(2);
3044 | });
3045 |
3046 | it('should support validateOnly with continueOnError mode', async () => {
3047 | const workflow = builder.build() as Workflow;
3048 |
3049 | const operations: WorkflowDiffOperation[] = [
3050 | {
3051 | type: 'updateName',
3052 | name: 'New Name'
3053 | } as UpdateNameOperation,
3054 | {
3055 | type: 'removeNode',
3056 | nodeId: 'non-existent'
3057 | } as RemoveNodeOperation,
3058 | {
3059 | type: 'addTag',
3060 | tag: 'test-tag'
3061 | } as AddTagOperation
3062 | ];
3063 |
3064 | const request: WorkflowDiffRequest = {
3065 | id: 'test-workflow',
3066 | operations,
3067 | continueOnError: true,
3068 | validateOnly: true
3069 | };
3070 |
3071 | const result = await diffEngine.applyDiff(workflow, request);
3072 |
3073 | expect(result.workflow).toBeUndefined();
3074 | expect(result.message).toContain('Validation completed');
3075 | expect(result.applied).toEqual([0, 2]);
3076 | expect(result.failed).toEqual([1]);
3077 | expect(result.errors).toHaveLength(1);
3078 | });
3079 |
3080 | it('should handle all operations failing with helpful message', async () => {
3081 | const workflow = builder.build() as Workflow;
3082 |
3083 | const operations: WorkflowDiffOperation[] = [
3084 | {
3085 | type: 'removeNode',
3086 | nodeId: 'non-existent-1'
3087 | } as RemoveNodeOperation,
3088 | {
3089 | type: 'removeNode',
3090 | nodeId: 'non-existent-2'
3091 | } as RemoveNodeOperation,
3092 | {
3093 | type: 'removeConnection',
3094 | source: 'Invalid',
3095 | target: 'Invalid'
3096 | } as RemoveConnectionOperation
3097 | ];
3098 |
3099 | const request: WorkflowDiffRequest = {
3100 | id: 'test-workflow',
3101 | operations,
3102 | continueOnError: true
3103 | };
3104 |
3105 | const result = await diffEngine.applyDiff(workflow, request);
3106 |
3107 | expect(result.success).toBe(false);
3108 | expect(result.applied).toHaveLength(0);
3109 | expect(result.failed).toEqual([0, 1, 2]);
3110 | expect(result.errors).toHaveLength(3);
3111 | expect(result.message).toContain('0 operations');
3112 | expect(result.message).toContain('3 failed');
3113 | });
3114 |
3115 | it('should preserve operation order in applied and failed arrays', async () => {
3116 | const workflow = builder.build() as Workflow;
3117 |
3118 | const operations: WorkflowDiffOperation[] = [
3119 | { type: 'updateName', name: 'Name1' } as UpdateNameOperation, // 0 - success
3120 | { type: 'removeNode', nodeId: 'invalid1' } as RemoveNodeOperation, // 1 - fail
3121 | { type: 'addTag', tag: 'tag1' } as AddTagOperation, // 2 - success
3122 | { type: 'removeNode', nodeId: 'invalid2' } as RemoveNodeOperation, // 3 - fail
3123 | { type: 'addTag', tag: 'tag2' } as AddTagOperation, // 4 - success
3124 | { type: 'removeNode', nodeId: 'invalid3' } as RemoveNodeOperation, // 5 - fail
3125 | ];
3126 |
3127 | const request: WorkflowDiffRequest = {
3128 | id: 'test-workflow',
3129 | operations,
3130 | continueOnError: true
3131 | };
3132 |
3133 | const result = await diffEngine.applyDiff(workflow, request);
3134 |
3135 | expect(result.success).toBe(true);
3136 | expect(result.applied).toEqual([0, 2, 4]);
3137 | expect(result.failed).toEqual([1, 3, 5]);
3138 | });
3139 | });
3140 |
3141 | describe('Edge Cases and Error Paths', () => {
3142 | it('should handle workflow with initialized but empty connections', async () => {
3143 | const workflow = builder.build() as Workflow;
3144 | // Start with empty connections
3145 | workflow.connections = {};
3146 |
3147 | // Add some nodes but no connections
3148 | workflow.nodes.push({
3149 | id: 'orphan-1',
3150 | name: 'Orphan Node',
3151 | type: 'n8n-nodes-base.code',
3152 | typeVersion: 1,
3153 | position: [800, 400],
3154 | parameters: {}
3155 | });
3156 |
3157 | const operations: CleanStaleConnectionsOperation[] = [{
3158 | type: 'cleanStaleConnections'
3159 | }];
3160 |
3161 | const request: WorkflowDiffRequest = {
3162 | id: 'test-workflow',
3163 | operations
3164 | };
3165 |
3166 | const result = await diffEngine.applyDiff(workflow, request);
3167 |
3168 | expect(result.success).toBe(true);
3169 | expect(result.workflow.connections).toEqual({});
3170 | });
3171 |
3172 | it('should handle empty connections in cleanStaleConnections', async () => {
3173 | const workflow = builder.build() as Workflow;
3174 | workflow.connections = {};
3175 |
3176 | const operations: CleanStaleConnectionsOperation[] = [{
3177 | type: 'cleanStaleConnections'
3178 | }];
3179 |
3180 | const request: WorkflowDiffRequest = {
3181 | id: 'test-workflow',
3182 | operations
3183 | };
3184 |
3185 | const result = await diffEngine.applyDiff(workflow, request);
3186 |
3187 | expect(result.success).toBe(true);
3188 | expect(result.workflow.connections).toEqual({});
3189 | });
3190 |
3191 | it('should handle removeConnection with ignoreErrors on valid but non-connected nodes', async () => {
3192 | const workflow = builder.build() as Workflow;
3193 |
3194 | // Both nodes exist but no connection between them
3195 | const operations: RemoveConnectionOperation[] = [{
3196 | type: 'removeConnection',
3197 | source: 'Slack',
3198 | target: 'Webhook',
3199 | ignoreErrors: true
3200 | }];
3201 |
3202 | const request: WorkflowDiffRequest = {
3203 | id: 'test-workflow',
3204 | operations
3205 | };
3206 |
3207 | const result = await diffEngine.applyDiff(workflow, request);
3208 |
3209 | expect(result.success).toBe(true);
3210 | });
3211 |
3212 | it('should handle replaceConnections with nested connection arrays', async () => {
3213 | const workflow = builder.build() as Workflow;
3214 |
3215 | const newConnections = {
3216 | 'Webhook': {
3217 | 'main': [
3218 | [
3219 | { node: 'HTTP Request', type: 'main', index: 0 },
3220 | { node: 'Slack', type: 'main', index: 0 }
3221 | ],
3222 | [
3223 | { node: 'HTTP Request', type: 'main', index: 1 }
3224 | ]
3225 | ]
3226 | }
3227 | };
3228 |
3229 | const operations: ReplaceConnectionsOperation[] = [{
3230 | type: 'replaceConnections',
3231 | connections: newConnections
3232 | }];
3233 |
3234 | const request: WorkflowDiffRequest = {
3235 | id: 'test-workflow',
3236 | operations
3237 | };
3238 |
3239 | const result = await diffEngine.applyDiff(workflow, request);
3240 |
3241 | expect(result.success).toBe(true);
3242 | expect(result.workflow.connections['Webhook']['main']).toHaveLength(2);
3243 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(2);
3244 | expect(result.workflow.connections['Webhook']['main'][1]).toHaveLength(1);
3245 | });
3246 |
3247 | it('should validate cleanStaleConnections always returns null', async () => {
3248 | const workflow = builder.build() as Workflow;
3249 |
3250 | // This tests that validation for cleanStaleConnections always passes
3251 | const operations: CleanStaleConnectionsOperation[] = [{
3252 | type: 'cleanStaleConnections'
3253 | }];
3254 |
3255 | const request: WorkflowDiffRequest = {
3256 | id: 'test-workflow',
3257 | operations,
3258 | validateOnly: true
3259 | };
3260 |
3261 | const result = await diffEngine.applyDiff(workflow, request);
3262 |
3263 | expect(result.success).toBe(true);
3264 | expect(result.message).toContain('Validation successful');
3265 | });
3266 |
3267 | it('should handle continueOnError with no operations', async () => {
3268 | const workflow = builder.build() as Workflow;
3269 |
3270 | const request: WorkflowDiffRequest = {
3271 | id: 'test-workflow',
3272 | operations: [],
3273 | continueOnError: true
3274 | };
3275 |
3276 | const result = await diffEngine.applyDiff(workflow, request);
3277 |
3278 | expect(result.success).toBe(false);
3279 | expect(result.applied).toEqual([]);
3280 | expect(result.failed).toEqual([]);
3281 | });
3282 | });
3283 |
3284 | describe('Integration Tests - v2.14.4 Features Combined', () => {
3285 | it('should combine cleanStaleConnections and replaceConnections', async () => {
3286 | const workflow = builder.build() as Workflow;
3287 |
3288 | // Add stale connections
3289 | workflow.connections['GhostNode'] = {
3290 | 'main': [[{ node: 'Slack', type: 'main', index: 0 }]]
3291 | };
3292 |
3293 | const operations: WorkflowDiffOperation[] = [
3294 | {
3295 | type: 'cleanStaleConnections'
3296 | } as CleanStaleConnectionsOperation,
3297 | {
3298 | type: 'replaceConnections',
3299 | connections: {
3300 | 'Webhook': {
3301 | 'main': [[{ node: 'Slack', type: 'main', index: 0 }]]
3302 | }
3303 | }
3304 | } as ReplaceConnectionsOperation
3305 | ];
3306 |
3307 | const request: WorkflowDiffRequest = {
3308 | id: 'test-workflow',
3309 | operations
3310 | };
3311 |
3312 | const result = await diffEngine.applyDiff(workflow, request);
3313 |
3314 | expect(result.success).toBe(true);
3315 | expect(result.workflow.connections['GhostNode']).toBeUndefined();
3316 | expect(result.workflow.connections['Webhook']['main'][0][0].node).toBe('Slack');
3317 | });
3318 |
3319 | it('should use continueOnError with new v2.14.4 operations', async () => {
3320 | const workflow = builder.build() as Workflow;
3321 |
3322 | const operations: WorkflowDiffOperation[] = [
3323 | {
3324 | type: 'cleanStaleConnections'
3325 | } as CleanStaleConnectionsOperation,
3326 | {
3327 | type: 'replaceConnections',
3328 | connections: {
3329 | 'NonExistentNode': {
3330 | 'main': [[{ node: 'Slack', type: 'main', index: 0 }]]
3331 | }
3332 | }
3333 | } as ReplaceConnectionsOperation,
3334 | {
3335 | type: 'removeConnection',
3336 | source: 'Webhook',
3337 | target: 'NonExistent',
3338 | ignoreErrors: true
3339 | } as RemoveConnectionOperation,
3340 | {
3341 | type: 'addTag',
3342 | tag: 'final-tag'
3343 | } as AddTagOperation
3344 | ];
3345 |
3346 | const request: WorkflowDiffRequest = {
3347 | id: 'test-workflow',
3348 | operations,
3349 | continueOnError: true
3350 | };
3351 |
3352 | const result = await diffEngine.applyDiff(workflow, request);
3353 |
3354 | expect(result.success).toBe(true);
3355 | expect(result.applied).toContain(0); // cleanStaleConnections
3356 | expect(result.failed).toContain(1); // replaceConnections with invalid node
3357 | expect(result.applied).toContain(2); // removeConnection with ignoreErrors
3358 | expect(result.applied).toContain(3); // addTag
3359 | expect(result.workflow.tags).toContain('final-tag');
3360 | });
3361 | });
3362 |
3363 | describe('Additional Edge Cases for 90% Coverage', () => {
3364 | it('should handle cleanStaleConnections with connections from valid node to itself', async () => {
3365 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3366 |
3367 | // Add self-referencing connection
3368 | if (!workflow.connections['Webhook']) {
3369 | workflow.connections['Webhook'] = {};
3370 | }
3371 | workflow.connections['Webhook']['main'] = [[
3372 | { node: 'Webhook', type: 'main', index: 0 },
3373 | { node: 'HTTP Request', type: 'main', index: 0 }
3374 | ]];
3375 |
3376 | const operations: CleanStaleConnectionsOperation[] = [{
3377 | type: 'cleanStaleConnections'
3378 | }];
3379 |
3380 | const request: WorkflowDiffRequest = {
3381 | id: 'test-workflow',
3382 | operations
3383 | };
3384 |
3385 | const result = await diffEngine.applyDiff(workflow, request);
3386 |
3387 | expect(result.success).toBe(true);
3388 | // Self-referencing connection should remain (it's valid)
3389 | expect(result.workflow.connections['Webhook']['main'][0].some((c: any) => c.node === 'Webhook')).toBe(true);
3390 | });
3391 |
3392 | it('should handle removeTag when tags array does not exist', async () => {
3393 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3394 | delete workflow.tags;
3395 |
3396 | const operations: RemoveTagOperation[] = [{
3397 | type: 'removeTag',
3398 | tag: 'non-existent'
3399 | }];
3400 |
3401 | const request: WorkflowDiffRequest = {
3402 | id: 'test-workflow',
3403 | operations
3404 | };
3405 |
3406 | const result = await diffEngine.applyDiff(workflow, request);
3407 |
3408 | expect(result.success).toBe(true);
3409 | });
3410 |
3411 | it('should handle cleanStaleConnections with multiple connection indices', async () => {
3412 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3413 |
3414 | // Add connections with multiple indices
3415 | workflow.connections['Webhook'] = {
3416 | 'main': [
3417 | [
3418 | { node: 'HTTP Request', type: 'main', index: 0 },
3419 | { node: 'Slack', type: 'main', index: 0 }
3420 | ],
3421 | [
3422 | { node: 'StaleNode', type: 'main', index: 0 }
3423 | ]
3424 | ]
3425 | };
3426 |
3427 | const operations: CleanStaleConnectionsOperation[] = [{
3428 | type: 'cleanStaleConnections'
3429 | }];
3430 |
3431 | const request: WorkflowDiffRequest = {
3432 | id: 'test-workflow',
3433 | operations
3434 | };
3435 |
3436 | const result = await diffEngine.applyDiff(workflow, request);
3437 |
3438 | expect(result.success).toBe(true);
3439 | // First index should remain with both valid connections
3440 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(2);
3441 | // Second index with stale node should be removed, so only one index remains
3442 | expect(result.workflow.connections['Webhook']['main'].length).toBe(1);
3443 | });
3444 |
3445 | it('should handle continueOnError with runtime error during apply', async () => {
3446 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3447 |
3448 | // Create a scenario that might cause runtime errors
3449 | const operations: WorkflowDiffOperation[] = [
3450 | {
3451 | type: 'updateNode',
3452 | nodeId: 'webhook-1',
3453 | updates: {
3454 | 'parameters.test': 'value1'
3455 | }
3456 | } as UpdateNodeOperation,
3457 | {
3458 | type: 'removeNode',
3459 | nodeId: 'invalid-node'
3460 | } as RemoveNodeOperation,
3461 | {
3462 | type: 'updateNode',
3463 | nodeName: 'HTTP Request',
3464 | updates: {
3465 | 'parameters.test': 'value2'
3466 | }
3467 | } as UpdateNodeOperation
3468 | ];
3469 |
3470 | const request: WorkflowDiffRequest = {
3471 | id: 'test-workflow',
3472 | operations,
3473 | continueOnError: true
3474 | };
3475 |
3476 | const result = await diffEngine.applyDiff(workflow, request);
3477 |
3478 | expect(result.applied).toContain(0);
3479 | expect(result.failed).toContain(1);
3480 | expect(result.applied).toContain(2);
3481 | });
3482 |
3483 | it('should handle atomic mode failure in node operations', async () => {
3484 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3485 |
3486 | const operations: WorkflowDiffOperation[] = [
3487 | {
3488 | type: 'updateNode',
3489 | nodeId: 'webhook-1',
3490 | updates: {
3491 | 'parameters.valid': 'update'
3492 | }
3493 | } as UpdateNodeOperation,
3494 | {
3495 | type: 'removeNode',
3496 | nodeId: 'invalid-node'
3497 | } as RemoveNodeOperation
3498 | ];
3499 |
3500 | const request: WorkflowDiffRequest = {
3501 | id: 'test-workflow',
3502 | operations
3503 | };
3504 |
3505 | const result = await diffEngine.applyDiff(workflow, request);
3506 |
3507 | expect(result.success).toBe(false);
3508 | expect(result.errors).toHaveLength(1);
3509 | expect(result.errors![0].operation).toBe(1);
3510 | });
3511 |
3512 | it('should handle atomic mode failure in connection operations', async () => {
3513 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3514 |
3515 | const operations: WorkflowDiffOperation[] = [
3516 | {
3517 | type: 'addNode',
3518 | node: {
3519 | name: 'NewNode',
3520 | type: 'n8n-nodes-base.code',
3521 | position: [900, 300],
3522 | parameters: {}
3523 | }
3524 | } as AddNodeOperation,
3525 | {
3526 | type: 'addConnection',
3527 | source: 'NewNode',
3528 | target: 'InvalidTarget'
3529 | } as AddConnectionOperation
3530 | ];
3531 |
3532 | const request: WorkflowDiffRequest = {
3533 | id: 'test-workflow',
3534 | operations
3535 | };
3536 |
3537 | const result = await diffEngine.applyDiff(workflow, request);
3538 |
3539 | expect(result.success).toBe(false);
3540 | expect(result.errors).toHaveLength(1);
3541 | expect(result.errors![0].operation).toBe(1);
3542 | });
3543 |
3544 | it('should handle cleanStaleConnections in dryRun with source node missing', async () => {
3545 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3546 |
3547 | // Add connections from non-existent source
3548 | workflow.connections['GhostSource1'] = {
3549 | 'main': [[{ node: 'Slack', type: 'main', index: 0 }]]
3550 | };
3551 |
3552 | workflow.connections['GhostSource2'] = {
3553 | 'main': [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
3554 | 'error': [[{ node: 'Slack', type: 'main', index: 0 }]]
3555 | };
3556 |
3557 | const operations: CleanStaleConnectionsOperation[] = [{
3558 | type: 'cleanStaleConnections',
3559 | dryRun: true
3560 | }];
3561 |
3562 | const request: WorkflowDiffRequest = {
3563 | id: 'test-workflow',
3564 | operations
3565 | };
3566 |
3567 | const result = await diffEngine.applyDiff(workflow, request);
3568 |
3569 | expect(result.success).toBe(true);
3570 | // In dryRun, connections should remain
3571 | expect(result.workflow.connections['GhostSource1']).toBeDefined();
3572 | expect(result.workflow.connections['GhostSource2']).toBeDefined();
3573 | });
3574 |
3575 | it('should handle validateOnly in atomic mode', async () => {
3576 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3577 |
3578 | const operations: WorkflowDiffOperation[] = [
3579 | {
3580 | type: 'updateName',
3581 | name: 'Validated Name'
3582 | } as UpdateNameOperation,
3583 | {
3584 | type: 'addNode',
3585 | node: {
3586 | name: 'ValidNode',
3587 | type: 'n8n-nodes-base.code',
3588 | position: [900, 300],
3589 | parameters: {}
3590 | }
3591 | } as AddNodeOperation
3592 | ];
3593 |
3594 | const request: WorkflowDiffRequest = {
3595 | id: 'test-workflow',
3596 | operations,
3597 | validateOnly: true
3598 | };
3599 |
3600 | const result = await diffEngine.applyDiff(workflow, request);
3601 |
3602 | expect(result.success).toBe(true);
3603 | expect(result.workflow).toBeUndefined();
3604 | expect(result.message).toContain('Validation successful');
3605 | expect(result.message).toContain('not applied');
3606 | });
3607 |
3608 | it('should handle malformed workflow object gracefully', async () => {
3609 | // Create a malformed workflow that will cause JSON parsing errors
3610 | const malformedWorkflow: any = {
3611 | name: 'Test',
3612 | nodes: [],
3613 | connections: {}
3614 | };
3615 |
3616 | // Create circular reference to cause JSON.stringify to fail
3617 | malformedWorkflow.self = malformedWorkflow;
3618 |
3619 | const operations: WorkflowDiffOperation[] = [{
3620 | type: 'updateName',
3621 | name: 'New Name'
3622 | } as UpdateNameOperation];
3623 |
3624 | const request: WorkflowDiffRequest = {
3625 | id: 'test-workflow',
3626 | operations
3627 | };
3628 |
3629 | const result = await diffEngine.applyDiff(malformedWorkflow, request);
3630 |
3631 | // Should handle the error gracefully
3632 | expect(result.success).toBe(false);
3633 | expect(result.errors).toBeDefined();
3634 | });
3635 |
3636 | it('should handle continueOnError with all operations causing errors', async () => {
3637 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3638 |
3639 | const operations: WorkflowDiffOperation[] = [
3640 | {
3641 | type: 'removeNode',
3642 | nodeId: 'invalid1'
3643 | } as RemoveNodeOperation,
3644 | {
3645 | type: 'removeNode',
3646 | nodeId: 'invalid2'
3647 | } as RemoveNodeOperation,
3648 | {
3649 | type: 'addConnection',
3650 | source: 'Invalid1',
3651 | target: 'Invalid2'
3652 | } as AddConnectionOperation
3653 | ];
3654 |
3655 | const request: WorkflowDiffRequest = {
3656 | id: 'test-workflow',
3657 | operations,
3658 | continueOnError: true
3659 | };
3660 |
3661 | const result = await diffEngine.applyDiff(workflow, request);
3662 |
3663 | expect(result.success).toBe(false);
3664 | expect(result.applied).toEqual([]);
3665 | expect(result.failed).toEqual([0, 1, 2]);
3666 | expect(result.errors).toHaveLength(3);
3667 | });
3668 |
3669 | it('should handle atomic mode with empty operations array', async () => {
3670 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3671 |
3672 | const request: WorkflowDiffRequest = {
3673 | id: 'test-workflow',
3674 | operations: []
3675 | };
3676 |
3677 | const result = await diffEngine.applyDiff(workflow, request);
3678 |
3679 | expect(result.success).toBe(true);
3680 | expect(result.operationsApplied).toBe(0);
3681 | });
3682 |
3683 | it('should handle removeConnection without sourceOutput specified', async () => {
3684 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3685 |
3686 | const operations: RemoveConnectionOperation[] = [{
3687 | type: 'removeConnection',
3688 | source: 'Webhook',
3689 | target: 'HTTP Request'
3690 | // sourceOutput not specified, should default to 'main'
3691 | }];
3692 |
3693 | const request: WorkflowDiffRequest = {
3694 | id: 'test-workflow',
3695 | operations
3696 | };
3697 |
3698 | const result = await diffEngine.applyDiff(workflow, request);
3699 |
3700 | expect(result.success).toBe(true);
3701 | });
3702 |
3703 | it('should handle continueOnError validateOnly with all errors', async () => {
3704 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3705 |
3706 | const operations: WorkflowDiffOperation[] = [
3707 | {
3708 | type: 'removeNode',
3709 | nodeId: 'invalid1'
3710 | } as RemoveNodeOperation,
3711 | {
3712 | type: 'removeNode',
3713 | nodeId: 'invalid2'
3714 | } as RemoveNodeOperation
3715 | ];
3716 |
3717 | const request: WorkflowDiffRequest = {
3718 | id: 'test-workflow',
3719 | operations,
3720 | continueOnError: true,
3721 | validateOnly: true
3722 | };
3723 |
3724 | const result = await diffEngine.applyDiff(workflow, request);
3725 |
3726 | expect(result.success).toBe(false);
3727 | expect(result.message).toContain('Validation completed');
3728 | expect(result.errors).toHaveLength(2);
3729 | expect(result.workflow).toBeUndefined();
3730 | });
3731 |
3732 |
3733 | it('should handle addConnection with all optional parameters specified', async () => {
3734 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3735 |
3736 | // Add Code node
3737 | workflow.nodes.push({
3738 | id: 'code-1',
3739 | name: 'Code',
3740 | type: 'n8n-nodes-base.code',
3741 | typeVersion: 1,
3742 | position: [900, 300],
3743 | parameters: {}
3744 | });
3745 |
3746 | const operations: AddConnectionOperation[] = [{
3747 | type: 'addConnection',
3748 | source: 'Slack',
3749 | target: 'Code',
3750 | sourceOutput: 'main',
3751 | targetInput: 'main',
3752 | sourceIndex: 0,
3753 | targetIndex: 0
3754 | }];
3755 |
3756 | const request: WorkflowDiffRequest = {
3757 | id: 'test-workflow',
3758 | operations
3759 | };
3760 |
3761 | const result = await diffEngine.applyDiff(workflow, request);
3762 |
3763 | expect(result.success).toBe(true);
3764 | expect(result.workflow.connections['Slack']['main'][0][0].node).toBe('Code');
3765 | expect(result.workflow.connections['Slack']['main'][0][0].type).toBe('main');
3766 | expect(result.workflow.connections['Slack']['main'][0][0].index).toBe(0);
3767 | });
3768 |
3769 | it('should handle cleanStaleConnections actually removing source node connections', async () => {
3770 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3771 |
3772 | // Add connections from non-existent source that should be deleted entirely
3773 | workflow.connections['NonExistentSource1'] = {
3774 | 'main': [[
3775 | { node: 'Slack', type: 'main', index: 0 }
3776 | ]]
3777 | };
3778 |
3779 | workflow.connections['NonExistentSource2'] = {
3780 | 'main': [[
3781 | { node: 'HTTP Request', type: 'main', index: 0 }
3782 | ]],
3783 | 'error': [[
3784 | { node: 'Slack', type: 'main', index: 0 }
3785 | ]]
3786 | };
3787 |
3788 | const operations: CleanStaleConnectionsOperation[] = [{
3789 | type: 'cleanStaleConnections'
3790 | }];
3791 |
3792 | const request: WorkflowDiffRequest = {
3793 | id: 'test-workflow',
3794 | operations
3795 | };
3796 |
3797 | const result = await diffEngine.applyDiff(workflow, request);
3798 |
3799 | expect(result.success).toBe(true);
3800 | expect(result.workflow.connections['NonExistentSource1']).toBeUndefined();
3801 | expect(result.workflow.connections['NonExistentSource2']).toBeUndefined();
3802 | });
3803 |
3804 | it('should handle validateOnly with no errors in continueOnError mode', async () => {
3805 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3806 |
3807 | const operations: WorkflowDiffOperation[] = [
3808 | {
3809 | type: 'updateName',
3810 | name: 'Valid Name'
3811 | } as UpdateNameOperation,
3812 | {
3813 | type: 'addTag',
3814 | tag: 'valid-tag'
3815 | } as AddTagOperation
3816 | ];
3817 |
3818 | const request: WorkflowDiffRequest = {
3819 | id: 'test-workflow',
3820 | operations,
3821 | continueOnError: true,
3822 | validateOnly: true
3823 | };
3824 |
3825 | const result = await diffEngine.applyDiff(workflow, request);
3826 |
3827 | expect(result.success).toBe(true);
3828 | expect(result.message).toContain('Validation successful');
3829 | expect(result.errors).toBeUndefined();
3830 | expect(result.applied).toEqual([0, 1]);
3831 | expect(result.failed).toEqual([]);
3832 | });
3833 |
3834 | it('should handle addConnection initializing missing connection structure', async () => {
3835 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3836 |
3837 | // Add node without any connections
3838 | workflow.nodes.push({
3839 | id: 'orphan-1',
3840 | name: 'Orphan',
3841 | type: 'n8n-nodes-base.code',
3842 | typeVersion: 1,
3843 | position: [900, 300],
3844 | parameters: {}
3845 | });
3846 |
3847 | // Ensure Orphan has no connections initially
3848 | delete workflow.connections['Orphan'];
3849 |
3850 | const operations: AddConnectionOperation[] = [{
3851 | type: 'addConnection',
3852 | source: 'Orphan',
3853 | target: 'Slack'
3854 | }];
3855 |
3856 | const request: WorkflowDiffRequest = {
3857 | id: 'test-workflow',
3858 | operations
3859 | };
3860 |
3861 | const result = await diffEngine.applyDiff(workflow, request);
3862 |
3863 | expect(result.success).toBe(true);
3864 | expect(result.workflow.connections['Orphan']).toBeDefined();
3865 | expect(result.workflow.connections['Orphan']['main']).toBeDefined();
3866 | expect(result.workflow.connections['Orphan']['main'][0][0].node).toBe('Slack');
3867 | });
3868 |
3869 | it('should handle addConnection with sourceIndex requiring array expansion', async () => {
3870 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3871 |
3872 | // Add Code node
3873 | workflow.nodes.push({
3874 | id: 'code-1',
3875 | name: 'Code',
3876 | type: 'n8n-nodes-base.code',
3877 | typeVersion: 1,
3878 | position: [900, 300],
3879 | parameters: {}
3880 | });
3881 |
3882 | const operations: AddConnectionOperation[] = [{
3883 | type: 'addConnection',
3884 | source: 'Slack',
3885 | target: 'Code',
3886 | sourceIndex: 5 // Force array expansion to index 5
3887 | }];
3888 |
3889 | const request: WorkflowDiffRequest = {
3890 | id: 'test-workflow',
3891 | operations
3892 | };
3893 |
3894 | const result = await diffEngine.applyDiff(workflow, request);
3895 |
3896 | expect(result.success).toBe(true);
3897 | expect(result.workflow.connections['Slack']['main'].length).toBeGreaterThanOrEqual(6);
3898 | expect(result.workflow.connections['Slack']['main'][5][0].node).toBe('Code');
3899 | });
3900 |
3901 | it('should handle removeConnection cleaning up empty output structures', async () => {
3902 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3903 |
3904 | // Set up a connection that will leave empty structures after removal
3905 | workflow.connections['HTTP Request'] = {
3906 | 'main': [[
3907 | { node: 'Slack', type: 'main', index: 0 }
3908 | ]]
3909 | };
3910 |
3911 | const operations: RemoveConnectionOperation[] = [{
3912 | type: 'removeConnection',
3913 | source: 'HTTP Request',
3914 | target: 'Slack'
3915 | }];
3916 |
3917 | const request: WorkflowDiffRequest = {
3918 | id: 'test-workflow',
3919 | operations
3920 | };
3921 |
3922 | const result = await diffEngine.applyDiff(workflow, request);
3923 |
3924 | expect(result.success).toBe(true);
3925 | // Connection should be removed entirely (cleanup of empty structures)
3926 | expect(result.workflow.connections['HTTP Request']).toBeUndefined();
3927 | });
3928 |
3929 | it('should handle complex cleanStaleConnections scenario with mixed valid/invalid', async () => {
3930 | const workflow = JSON.parse(JSON.stringify(baseWorkflow));
3931 |
3932 | // Create a complex scenario with multiple source nodes
3933 | workflow.connections['Webhook'] = {
3934 | 'main': [[
3935 | { node: 'HTTP Request', type: 'main', index: 0 },
3936 | { node: 'Stale1', type: 'main', index: 0 },
3937 | { node: 'Slack', type: 'main', index: 0 },
3938 | { node: 'Stale2', type: 'main', index: 0 }
3939 | ]],
3940 | 'error': [[
3941 | { node: 'Stale3', type: 'main', index: 0 }
3942 | ]]
3943 | };
3944 |
3945 | const operations: CleanStaleConnectionsOperation[] = [{
3946 | type: 'cleanStaleConnections'
3947 | }];
3948 |
3949 | const request: WorkflowDiffRequest = {
3950 | id: 'test-workflow',
3951 | operations
3952 | };
3953 |
3954 | const result = await diffEngine.applyDiff(workflow, request);
3955 |
3956 | expect(result.success).toBe(true);
3957 | // Only valid connections should remain
3958 | expect(result.workflow.connections['Webhook']['main'][0]).toHaveLength(2);
3959 | expect(result.workflow.connections['Webhook']['main'][0].some((c: any) => c.node === 'HTTP Request')).toBe(true);
3960 | expect(result.workflow.connections['Webhook']['main'][0].some((c: any) => c.node === 'Slack')).toBe(true);
3961 | // Error output should be removed entirely (all stale)
3962 | expect(result.workflow.connections['Webhook']['error']).toBeUndefined();
3963 | });
3964 | });
3965 | });
3966 |
3967 | // Issue #270: Special characters in node names
3968 | describe('Special Characters in Node Names', () => {
3969 | it('should handle apostrophes in node names for addConnection', async () => {
3970 | // Default n8n Manual Trigger node name contains apostrophes
3971 | const workflowWithApostrophes = {
3972 | ...baseWorkflow,
3973 | nodes: [
3974 | ...baseWorkflow.nodes,
3975 | {
3976 | id: 'manual-trigger-1',
3977 | name: "When clicking 'Execute workflow'", // Contains apostrophes
3978 | type: 'n8n-nodes-base.manualTrigger',
3979 | typeVersion: 1,
3980 | position: [100, 100] as [number, number],
3981 | parameters: {}
3982 | }
3983 | ]
3984 | };
3985 |
3986 | const operation: AddConnectionOperation = {
3987 | type: 'addConnection',
3988 | source: "When clicking 'Execute workflow'", // Using node name with apostrophes
3989 | target: 'HTTP Request'
3990 | };
3991 |
3992 | const request: WorkflowDiffRequest = {
3993 | id: 'test-workflow',
3994 | operations: [operation]
3995 | };
3996 |
3997 | const result = await diffEngine.applyDiff(workflowWithApostrophes as Workflow, request);
3998 |
3999 | expect(result.success).toBe(true);
4000 | expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined();
4001 | expect(result.workflow.connections["When clicking 'Execute workflow'"].main).toBeDefined();
4002 | });
4003 |
4004 | it('should handle double quotes in node names', async () => {
4005 | const workflowWithQuotes = {
4006 | ...baseWorkflow,
4007 | nodes: [
4008 | ...baseWorkflow.nodes,
4009 | {
4010 | id: 'quoted-node-1',
4011 | name: 'Node with "quotes"', // Contains double quotes
4012 | type: 'n8n-nodes-base.set',
4013 | typeVersion: 1,
4014 | position: [100, 100] as [number, number],
4015 | parameters: {}
4016 | }
4017 | ]
4018 | };
4019 |
4020 | const operation: AddConnectionOperation = {
4021 | type: 'addConnection',
4022 | source: 'Node with "quotes"',
4023 | target: 'HTTP Request'
4024 | };
4025 |
4026 | const request: WorkflowDiffRequest = {
4027 | id: 'test-workflow',
4028 | operations: [operation]
4029 | };
4030 |
4031 | const result = await diffEngine.applyDiff(workflowWithQuotes as Workflow, request);
4032 |
4033 | expect(result.success).toBe(true);
4034 | expect(result.workflow.connections['Node with "quotes"']).toBeDefined();
4035 | });
4036 |
4037 | it('should handle backslashes in node names', async () => {
4038 | const workflowWithBackslashes = {
4039 | ...baseWorkflow,
4040 | nodes: [
4041 | ...baseWorkflow.nodes,
4042 | {
4043 | id: 'backslash-node-1',
4044 | name: 'Path\\with\\backslashes', // Contains backslashes
4045 | type: 'n8n-nodes-base.set',
4046 | typeVersion: 1,
4047 | position: [100, 100] as [number, number],
4048 | parameters: {}
4049 | }
4050 | ]
4051 | };
4052 |
4053 | const operation: AddConnectionOperation = {
4054 | type: 'addConnection',
4055 | source: 'Path\\with\\backslashes',
4056 | target: 'HTTP Request'
4057 | };
4058 |
4059 | const request: WorkflowDiffRequest = {
4060 | id: 'test-workflow',
4061 | operations: [operation]
4062 | };
4063 |
4064 | const result = await diffEngine.applyDiff(workflowWithBackslashes as Workflow, request);
4065 |
4066 | expect(result.success).toBe(true);
4067 | expect(result.workflow.connections['Path\\with\\backslashes']).toBeDefined();
4068 | });
4069 |
4070 | it('should handle mixed special characters in node names', async () => {
4071 | const workflowWithMixed = {
4072 | ...baseWorkflow,
4073 | nodes: [
4074 | ...baseWorkflow.nodes,
4075 | {
4076 | id: 'complex-node-1',
4077 | name: "Complex 'name' with \"quotes\" and \\backslash",
4078 | type: 'n8n-nodes-base.set',
4079 | typeVersion: 1,
4080 | position: [100, 100] as [number, number],
4081 | parameters: {}
4082 | }
4083 | ]
4084 | };
4085 |
4086 | const operation: AddConnectionOperation = {
4087 | type: 'addConnection',
4088 | source: "Complex 'name' with \"quotes\" and \\backslash",
4089 | target: 'HTTP Request'
4090 | };
4091 |
4092 | const request: WorkflowDiffRequest = {
4093 | id: 'test-workflow',
4094 | operations: [operation]
4095 | };
4096 |
4097 | const result = await diffEngine.applyDiff(workflowWithMixed as Workflow, request);
4098 |
4099 | expect(result.success).toBe(true);
4100 | expect(result.workflow.connections["Complex 'name' with \"quotes\" and \\backslash"]).toBeDefined();
4101 | });
4102 |
4103 | it('should handle special characters in removeConnection', async () => {
4104 | const workflowWithConnections = {
4105 | ...baseWorkflow,
4106 | nodes: [
4107 | ...baseWorkflow.nodes,
4108 | {
4109 | id: 'apostrophe-node-1',
4110 | name: "Node with 'apostrophes'",
4111 | type: 'n8n-nodes-base.set',
4112 | typeVersion: 1,
4113 | position: [100, 100] as [number, number],
4114 | parameters: {}
4115 | }
4116 | ],
4117 | connections: {
4118 | ...baseWorkflow.connections,
4119 | "Node with 'apostrophes'": {
4120 | main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
4121 | }
4122 | }
4123 | };
4124 |
4125 | const operation: RemoveConnectionOperation = {
4126 | type: 'removeConnection',
4127 | source: "Node with 'apostrophes'",
4128 | target: 'HTTP Request'
4129 | };
4130 |
4131 | const request: WorkflowDiffRequest = {
4132 | id: 'test-workflow',
4133 | operations: [operation]
4134 | };
4135 |
4136 | const result = await diffEngine.applyDiff(workflowWithConnections as any, request);
4137 |
4138 | expect(result.success).toBe(true);
4139 | expect(result.workflow.connections["Node with 'apostrophes'"]).toBeUndefined();
4140 | });
4141 |
4142 | it('should handle special characters in updateNode', async () => {
4143 | const workflowWithSpecialNode = {
4144 | ...baseWorkflow,
4145 | nodes: [
4146 | ...baseWorkflow.nodes,
4147 | {
4148 | id: 'special-node-1',
4149 | name: "Update 'this' node",
4150 | type: 'n8n-nodes-base.set',
4151 | typeVersion: 1,
4152 | position: [100, 100] as [number, number],
4153 | parameters: { value: 'old' }
4154 | }
4155 | ]
4156 | };
4157 |
4158 | const operation: UpdateNodeOperation = {
4159 | type: 'updateNode',
4160 | nodeName: "Update 'this' node",
4161 | updates: {
4162 | 'parameters.value': 'new'
4163 | }
4164 | };
4165 |
4166 | const request: WorkflowDiffRequest = {
4167 | id: 'test-workflow',
4168 | operations: [operation]
4169 | };
4170 |
4171 | const result = await diffEngine.applyDiff(workflowWithSpecialNode as Workflow, request);
4172 |
4173 | expect(result.success).toBe(true);
4174 | const updatedNode = result.workflow.nodes.find((n: any) => n.name === "Update 'this' node");
4175 | expect(updatedNode?.parameters.value).toBe('new');
4176 | });
4177 |
4178 | // Code Review Fix: Test whitespace normalization
4179 | it('should handle tabs in node names', async () => {
4180 | const workflowWithTabs = {
4181 | ...baseWorkflow,
4182 | nodes: [
4183 | ...baseWorkflow.nodes,
4184 | {
4185 | id: 'tab-node-1',
4186 | name: "Node\twith\ttabs", // Contains tabs
4187 | type: 'n8n-nodes-base.set',
4188 | typeVersion: 1,
4189 | position: [100, 100] as [number, number],
4190 | parameters: {}
4191 | }
4192 | ]
4193 | };
4194 |
4195 | const operation: AddConnectionOperation = {
4196 | type: 'addConnection',
4197 | source: "Node\twith\ttabs", // Tabs should normalize to single spaces
4198 | target: 'HTTP Request'
4199 | };
4200 |
4201 | const request: WorkflowDiffRequest = {
4202 | id: 'test-workflow',
4203 | operations: [operation]
4204 | };
4205 |
4206 | const result = await diffEngine.applyDiff(workflowWithTabs as Workflow, request);
4207 |
4208 | expect(result.success).toBe(true);
4209 | // After normalization, both "Node\twith\ttabs" and "Node with tabs" should match
4210 | expect(result.workflow.connections["Node\twith\ttabs"]).toBeDefined();
4211 | });
4212 |
4213 | it('should handle newlines in node names', async () => {
4214 | const workflowWithNewlines = {
4215 | ...baseWorkflow,
4216 | nodes: [
4217 | ...baseWorkflow.nodes,
4218 | {
4219 | id: 'newline-node-1',
4220 | name: "Node\nwith\nnewlines", // Contains newlines
4221 | type: 'n8n-nodes-base.set',
4222 | typeVersion: 1,
4223 | position: [100, 100] as [number, number],
4224 | parameters: {}
4225 | }
4226 | ]
4227 | };
4228 |
4229 | const operation: AddConnectionOperation = {
4230 | type: 'addConnection',
4231 | source: "Node\nwith\nnewlines", // Newlines should normalize to single spaces
4232 | target: 'HTTP Request'
4233 | };
4234 |
4235 | const request: WorkflowDiffRequest = {
4236 | id: 'test-workflow',
4237 | operations: [operation]
4238 | };
4239 |
4240 | const result = await diffEngine.applyDiff(workflowWithNewlines as Workflow, request);
4241 |
4242 | expect(result.success).toBe(true);
4243 | expect(result.workflow.connections["Node\nwith\nnewlines"]).toBeDefined();
4244 | });
4245 |
4246 | it('should handle mixed whitespace (tabs, newlines, spaces)', async () => {
4247 | const workflowWithMixed = {
4248 | ...baseWorkflow,
4249 | nodes: [
4250 | ...baseWorkflow.nodes,
4251 | {
4252 | id: 'mixed-whitespace-node-1',
4253 | name: "Node\t \n with \r\nmixed", // Mixed whitespace
4254 | type: 'n8n-nodes-base.set',
4255 | typeVersion: 1,
4256 | position: [100, 100] as [number, number],
4257 | parameters: {}
4258 | }
4259 | ]
4260 | };
4261 |
4262 | const operation: AddConnectionOperation = {
4263 | type: 'addConnection',
4264 | source: "Node\t \n with \r\nmixed", // Should normalize all whitespace
4265 | target: 'HTTP Request'
4266 | };
4267 |
4268 | const request: WorkflowDiffRequest = {
4269 | id: 'test-workflow',
4270 | operations: [operation]
4271 | };
4272 |
4273 | const result = await diffEngine.applyDiff(workflowWithMixed as Workflow, request);
4274 |
4275 | expect(result.success).toBe(true);
4276 | expect(result.workflow.connections["Node\t \n with \r\nmixed"]).toBeDefined();
4277 | });
4278 |
4279 | // Code Review Fix: Test escaped vs unescaped matching (core issue #270 scenario)
4280 | it('should match escaped input with unescaped stored names (Issue #270 core scenario)', async () => {
4281 | // Scenario: AI/JSON-RPC sends escaped name, n8n workflow has unescaped name
4282 | const workflowWithUnescaped = {
4283 | ...baseWorkflow,
4284 | nodes: [
4285 | ...baseWorkflow.nodes,
4286 | {
4287 | id: 'test-node',
4288 | name: "When clicking 'Execute workflow'", // Unescaped (how n8n stores it)
4289 | type: 'n8n-nodes-base.manualTrigger',
4290 | typeVersion: 1,
4291 | position: [100, 100] as [number, number],
4292 | parameters: {}
4293 | }
4294 | ]
4295 | };
4296 |
4297 | const operation: AddConnectionOperation = {
4298 | type: 'addConnection',
4299 | source: "When clicking \\'Execute workflow\\'", // Escaped (how JSON-RPC might send it)
4300 | target: 'HTTP Request'
4301 | };
4302 |
4303 | const request: WorkflowDiffRequest = {
4304 | id: 'test-workflow',
4305 | operations: [operation]
4306 | };
4307 |
4308 | const result = await diffEngine.applyDiff(workflowWithUnescaped as Workflow, request);
4309 |
4310 | expect(result.success).toBe(true); // Should match despite different escaping
4311 | expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined();
4312 | });
4313 | });
4314 |
4315 | describe('Workflow Activation/Deactivation Operations', () => {
4316 | it('should activate workflow with activatable trigger nodes', async () => {
4317 | // Create workflow with webhook trigger (activatable)
4318 | const workflowWithTrigger = createWorkflow('Test Workflow')
4319 | .addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger' })
4320 | .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
4321 | .connect('webhook-1', 'http-1')
4322 | .build() as Workflow;
4323 |
4324 | // Fix connections to use node names
4325 | const newConnections: any = {};
4326 | for (const [nodeId, outputs] of Object.entries(workflowWithTrigger.connections)) {
4327 | const node = workflowWithTrigger.nodes.find((n: any) => n.id === nodeId);
4328 | if (node) {
4329 | newConnections[node.name] = {};
4330 | for (const [outputName, connections] of Object.entries(outputs)) {
4331 | newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
4332 | conns.map((conn: any) => {
4333 | const targetNode = workflowWithTrigger.nodes.find((n: any) => n.id === conn.node);
4334 | return { ...conn, node: targetNode ? targetNode.name : conn.node };
4335 | })
4336 | );
4337 | }
4338 | }
4339 | }
4340 | workflowWithTrigger.connections = newConnections;
4341 |
4342 | const operation: any = {
4343 | type: 'activateWorkflow'
4344 | };
4345 |
4346 | const request: WorkflowDiffRequest = {
4347 | id: 'test-workflow',
4348 | operations: [operation]
4349 | };
4350 |
4351 | const result = await diffEngine.applyDiff(workflowWithTrigger, request);
4352 |
4353 | expect(result.success).toBe(true);
4354 | expect(result.shouldActivate).toBe(true);
4355 | expect((result.workflow as any)._shouldActivate).toBeUndefined(); // Flag should be cleaned up
4356 | });
4357 |
4358 | it('should reject activation if no activatable trigger nodes', async () => {
4359 | // Create workflow with no trigger nodes at all
4360 | const workflowWithoutActivatableTrigger = createWorkflow('Test Workflow')
4361 | .addNode({
4362 | id: 'set-1',
4363 | name: 'Set Node',
4364 | type: 'n8n-nodes-base.set',
4365 | typeVersion: 1,
4366 | position: [100, 100],
4367 | parameters: {}
4368 | })
4369 | .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
4370 | .connect('set-1', 'http-1')
4371 | .build() as Workflow;
4372 |
4373 | // Fix connections to use node names
4374 | const newConnections: any = {};
4375 | for (const [nodeId, outputs] of Object.entries(workflowWithoutActivatableTrigger.connections)) {
4376 | const node = workflowWithoutActivatableTrigger.nodes.find((n: any) => n.id === nodeId);
4377 | if (node) {
4378 | newConnections[node.name] = {};
4379 | for (const [outputName, connections] of Object.entries(outputs)) {
4380 | newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
4381 | conns.map((conn: any) => {
4382 | const targetNode = workflowWithoutActivatableTrigger.nodes.find((n: any) => n.id === conn.node);
4383 | return { ...conn, node: targetNode ? targetNode.name : conn.node };
4384 | })
4385 | );
4386 | }
4387 | }
4388 | }
4389 | workflowWithoutActivatableTrigger.connections = newConnections;
4390 |
4391 | const operation: any = {
4392 | type: 'activateWorkflow'
4393 | };
4394 |
4395 | const request: WorkflowDiffRequest = {
4396 | id: 'test-workflow',
4397 | operations: [operation]
4398 | };
4399 |
4400 | const result = await diffEngine.applyDiff(workflowWithoutActivatableTrigger, request);
4401 |
4402 | expect(result.success).toBe(false);
4403 | expect(result.errors).toBeDefined();
4404 | expect(result.errors![0].message).toContain('No activatable trigger nodes found');
4405 | expect(result.errors![0].message).toContain('executeWorkflowTrigger cannot activate workflows');
4406 | });
4407 |
4408 | it('should reject activation if all trigger nodes are disabled', async () => {
4409 | // Create workflow with disabled webhook trigger
4410 | const workflowWithDisabledTrigger = createWorkflow('Test Workflow')
4411 | .addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger', disabled: true })
4412 | .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
4413 | .connect('webhook-1', 'http-1')
4414 | .build() as Workflow;
4415 |
4416 | // Fix connections to use node names
4417 | const newConnections: any = {};
4418 | for (const [nodeId, outputs] of Object.entries(workflowWithDisabledTrigger.connections)) {
4419 | const node = workflowWithDisabledTrigger.nodes.find((n: any) => n.id === nodeId);
4420 | if (node) {
4421 | newConnections[node.name] = {};
4422 | for (const [outputName, connections] of Object.entries(outputs)) {
4423 | newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
4424 | conns.map((conn: any) => {
4425 | const targetNode = workflowWithDisabledTrigger.nodes.find((n: any) => n.id === conn.node);
4426 | return { ...conn, node: targetNode ? targetNode.name : conn.node };
4427 | })
4428 | );
4429 | }
4430 | }
4431 | }
4432 | workflowWithDisabledTrigger.connections = newConnections;
4433 |
4434 | const operation: any = {
4435 | type: 'activateWorkflow'
4436 | };
4437 |
4438 | const request: WorkflowDiffRequest = {
4439 | id: 'test-workflow',
4440 | operations: [operation]
4441 | };
4442 |
4443 | const result = await diffEngine.applyDiff(workflowWithDisabledTrigger, request);
4444 |
4445 | expect(result.success).toBe(false);
4446 | expect(result.errors).toBeDefined();
4447 | expect(result.errors![0].message).toContain('No activatable trigger nodes found');
4448 | });
4449 |
4450 | it('should activate workflow with schedule trigger', async () => {
4451 | // Create workflow with schedule trigger (activatable)
4452 | const workflowWithSchedule = createWorkflow('Test Workflow')
4453 | .addNode({
4454 | id: 'schedule-1',
4455 | name: 'Schedule',
4456 | type: 'n8n-nodes-base.scheduleTrigger',
4457 | typeVersion: 1,
4458 | position: [100, 100],
4459 | parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 1 }] } }
4460 | })
4461 | .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
4462 | .connect('schedule-1', 'http-1')
4463 | .build() as Workflow;
4464 |
4465 | // Fix connections
4466 | const newConnections: any = {};
4467 | for (const [nodeId, outputs] of Object.entries(workflowWithSchedule.connections)) {
4468 | const node = workflowWithSchedule.nodes.find((n: any) => n.id === nodeId);
4469 | if (node) {
4470 | newConnections[node.name] = {};
4471 | for (const [outputName, connections] of Object.entries(outputs)) {
4472 | newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
4473 | conns.map((conn: any) => {
4474 | const targetNode = workflowWithSchedule.nodes.find((n: any) => n.id === conn.node);
4475 | return { ...conn, node: targetNode ? targetNode.name : conn.node };
4476 | })
4477 | );
4478 | }
4479 | }
4480 | }
4481 | workflowWithSchedule.connections = newConnections;
4482 |
4483 | const operation: any = {
4484 | type: 'activateWorkflow'
4485 | };
4486 |
4487 | const request: WorkflowDiffRequest = {
4488 | id: 'test-workflow',
4489 | operations: [operation]
4490 | };
4491 |
4492 | const result = await diffEngine.applyDiff(workflowWithSchedule, request);
4493 |
4494 | expect(result.success).toBe(true);
4495 | expect(result.shouldActivate).toBe(true);
4496 | });
4497 |
4498 | it('should deactivate workflow successfully', async () => {
4499 | // Any workflow can be deactivated
4500 | const operation: any = {
4501 | type: 'deactivateWorkflow'
4502 | };
4503 |
4504 | const request: WorkflowDiffRequest = {
4505 | id: 'test-workflow',
4506 | operations: [operation]
4507 | };
4508 |
4509 | const result = await diffEngine.applyDiff(baseWorkflow, request);
4510 |
4511 | expect(result.success).toBe(true);
4512 | expect(result.shouldDeactivate).toBe(true);
4513 | expect((result.workflow as any)._shouldDeactivate).toBeUndefined(); // Flag should be cleaned up
4514 | });
4515 |
4516 | it('should deactivate workflow without trigger nodes', async () => {
4517 | // Create workflow without any trigger nodes
4518 | const workflowWithoutTrigger = createWorkflow('Test Workflow')
4519 | .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
4520 | .addNode({
4521 | id: 'set-1',
4522 | name: 'Set',
4523 | type: 'n8n-nodes-base.set',
4524 | typeVersion: 1,
4525 | position: [300, 100],
4526 | parameters: {}
4527 | })
4528 | .connect('http-1', 'set-1')
4529 | .build() as Workflow;
4530 |
4531 | // Fix connections
4532 | const newConnections: any = {};
4533 | for (const [nodeId, outputs] of Object.entries(workflowWithoutTrigger.connections)) {
4534 | const node = workflowWithoutTrigger.nodes.find((n: any) => n.id === nodeId);
4535 | if (node) {
4536 | newConnections[node.name] = {};
4537 | for (const [outputName, connections] of Object.entries(outputs)) {
4538 | newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
4539 | conns.map((conn: any) => {
4540 | const targetNode = workflowWithoutTrigger.nodes.find((n: any) => n.id === conn.node);
4541 | return { ...conn, node: targetNode ? targetNode.name : conn.node };
4542 | })
4543 | );
4544 | }
4545 | }
4546 | }
4547 | workflowWithoutTrigger.connections = newConnections;
4548 |
4549 | const operation: any = {
4550 | type: 'deactivateWorkflow'
4551 | };
4552 |
4553 | const request: WorkflowDiffRequest = {
4554 | id: 'test-workflow',
4555 | operations: [operation]
4556 | };
4557 |
4558 | const result = await diffEngine.applyDiff(workflowWithoutTrigger, request);
4559 |
4560 | expect(result.success).toBe(true);
4561 | expect(result.shouldDeactivate).toBe(true);
4562 | });
4563 |
4564 | it('should combine activation with other operations', async () => {
4565 | // Create workflow with webhook trigger
4566 | const workflowWithTrigger = createWorkflow('Test Workflow')
4567 | .addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger' })
4568 | .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
4569 | .connect('webhook-1', 'http-1')
4570 | .build() as Workflow;
4571 |
4572 | // Fix connections
4573 | const newConnections: any = {};
4574 | for (const [nodeId, outputs] of Object.entries(workflowWithTrigger.connections)) {
4575 | const node = workflowWithTrigger.nodes.find((n: any) => n.id === nodeId);
4576 | if (node) {
4577 | newConnections[node.name] = {};
4578 | for (const [outputName, connections] of Object.entries(outputs)) {
4579 | newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
4580 | conns.map((conn: any) => {
4581 | const targetNode = workflowWithTrigger.nodes.find((n: any) => n.id === conn.node);
4582 | return { ...conn, node: targetNode ? targetNode.name : conn.node };
4583 | })
4584 | );
4585 | }
4586 | }
4587 | }
4588 | workflowWithTrigger.connections = newConnections;
4589 |
4590 | const operations: any[] = [
4591 | {
4592 | type: 'updateName',
4593 | name: 'Updated Workflow Name'
4594 | },
4595 | {
4596 | type: 'addTag',
4597 | tag: 'production'
4598 | },
4599 | {
4600 | type: 'activateWorkflow'
4601 | }
4602 | ];
4603 |
4604 | const request: WorkflowDiffRequest = {
4605 | id: 'test-workflow',
4606 | operations
4607 | };
4608 |
4609 | const result = await diffEngine.applyDiff(workflowWithTrigger, request);
4610 |
4611 | expect(result.success).toBe(true);
4612 | expect(result.operationsApplied).toBe(3);
4613 | expect(result.workflow!.name).toBe('Updated Workflow Name');
4614 | expect(result.workflow!.tags).toContain('production');
4615 | expect(result.shouldActivate).toBe(true);
4616 | });
4617 |
4618 | it('should reject activation if workflow has executeWorkflowTrigger only', async () => {
4619 | // Create workflow with executeWorkflowTrigger (not activatable - Issue #351)
4620 | const workflowWithExecuteTrigger = createWorkflow('Test Workflow')
4621 | .addNode({
4622 | id: 'execute-1',
4623 | name: 'Execute Workflow Trigger',
4624 | type: 'n8n-nodes-base.executeWorkflowTrigger',
4625 | typeVersion: 1,
4626 | position: [100, 100],
4627 | parameters: {}
4628 | })
4629 | .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
4630 | .connect('execute-1', 'http-1')
4631 | .build() as Workflow;
4632 |
4633 | // Fix connections
4634 | const newConnections: any = {};
4635 | for (const [nodeId, outputs] of Object.entries(workflowWithExecuteTrigger.connections)) {
4636 | const node = workflowWithExecuteTrigger.nodes.find((n: any) => n.id === nodeId);
4637 | if (node) {
4638 | newConnections[node.name] = {};
4639 | for (const [outputName, connections] of Object.entries(outputs)) {
4640 | newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
4641 | conns.map((conn: any) => {
4642 | const targetNode = workflowWithExecuteTrigger.nodes.find((n: any) => n.id === conn.node);
4643 | return { ...conn, node: targetNode ? targetNode.name : conn.node };
4644 | })
4645 | );
4646 | }
4647 | }
4648 | }
4649 | workflowWithExecuteTrigger.connections = newConnections;
4650 |
4651 | const operation: any = {
4652 | type: 'activateWorkflow'
4653 | };
4654 |
4655 | const request: WorkflowDiffRequest = {
4656 | id: 'test-workflow',
4657 | operations: [operation]
4658 | };
4659 |
4660 | const result = await diffEngine.applyDiff(workflowWithExecuteTrigger, request);
4661 |
4662 | expect(result.success).toBe(false);
4663 | expect(result.errors).toBeDefined();
4664 | expect(result.errors![0].message).toContain('No activatable trigger nodes found');
4665 | expect(result.errors![0].message).toContain('executeWorkflowTrigger cannot activate workflows');
4666 | });
4667 | });
4668 | });
```