#
tokens: 46238/50000 6/617 files (page 30/59)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 30 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── _config.yml
├── .claude
│   └── agents
│       ├── code-reviewer.md
│       ├── context-manager.md
│       ├── debugger.md
│       ├── deployment-engineer.md
│       ├── mcp-backend-engineer.md
│       ├── n8n-mcp-tester.md
│       ├── technical-researcher.md
│       └── test-automator.md
├── .dockerignore
├── .env.docker
├── .env.example
├── .env.n8n.example
├── .env.test
├── .env.test.example
├── .github
│   ├── ABOUT.md
│   ├── BENCHMARK_THRESHOLDS.md
│   ├── FUNDING.yml
│   ├── gh-pages.yml
│   ├── secret_scanning.yml
│   └── workflows
│       ├── benchmark-pr.yml
│       ├── benchmark.yml
│       ├── docker-build-fast.yml
│       ├── docker-build-n8n.yml
│       ├── docker-build.yml
│       ├── release.yml
│       ├── test.yml
│       └── update-n8n-deps.yml
├── .gitignore
├── .npmignore
├── ATTRIBUTION.md
├── CHANGELOG.md
├── CLAUDE.md
├── codecov.yml
├── coverage.json
├── data
│   ├── .gitkeep
│   ├── nodes.db
│   ├── nodes.db-shm
│   ├── nodes.db-wal
│   └── templates.db
├── deploy
│   └── quick-deploy-n8n.sh
├── docker
│   ├── docker-entrypoint.sh
│   ├── n8n-mcp
│   ├── parse-config.js
│   └── README.md
├── docker-compose.buildkit.yml
├── docker-compose.extract.yml
├── docker-compose.n8n.yml
├── docker-compose.override.yml.example
├── docker-compose.test-n8n.yml
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.railway
├── Dockerfile.test
├── docs
│   ├── AUTOMATED_RELEASES.md
│   ├── BENCHMARKS.md
│   ├── CHANGELOG.md
│   ├── CLAUDE_CODE_SETUP.md
│   ├── CLAUDE_INTERVIEW.md
│   ├── CODECOV_SETUP.md
│   ├── CODEX_SETUP.md
│   ├── CURSOR_SETUP.md
│   ├── DEPENDENCY_UPDATES.md
│   ├── DOCKER_README.md
│   ├── DOCKER_TROUBLESHOOTING.md
│   ├── FINAL_AI_VALIDATION_SPEC.md
│   ├── FLEXIBLE_INSTANCE_CONFIGURATION.md
│   ├── HTTP_DEPLOYMENT.md
│   ├── img
│   │   ├── cc_command.png
│   │   ├── cc_connected.png
│   │   ├── codex_connected.png
│   │   ├── cursor_tut.png
│   │   ├── Railway_api.png
│   │   ├── Railway_server_address.png
│   │   ├── vsc_ghcp_chat_agent_mode.png
│   │   ├── vsc_ghcp_chat_instruction_files.png
│   │   ├── vsc_ghcp_chat_thinking_tool.png
│   │   └── windsurf_tut.png
│   ├── INSTALLATION.md
│   ├── LIBRARY_USAGE.md
│   ├── local
│   │   ├── DEEP_DIVE_ANALYSIS_2025-10-02.md
│   │   ├── DEEP_DIVE_ANALYSIS_README.md
│   │   ├── Deep_dive_p1_p2.md
│   │   ├── integration-testing-plan.md
│   │   ├── integration-tests-phase1-summary.md
│   │   ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
│   │   ├── P0_IMPLEMENTATION_PLAN.md
│   │   └── TEMPLATE_MINING_ANALYSIS.md
│   ├── MCP_ESSENTIALS_README.md
│   ├── MCP_QUICK_START_GUIDE.md
│   ├── N8N_DEPLOYMENT.md
│   ├── RAILWAY_DEPLOYMENT.md
│   ├── README_CLAUDE_SETUP.md
│   ├── README.md
│   ├── tools-documentation-usage.md
│   ├── VS_CODE_PROJECT_SETUP.md
│   ├── WINDSURF_SETUP.md
│   └── workflow-diff-examples.md
├── examples
│   └── enhanced-documentation-demo.js
├── fetch_log.txt
├── LICENSE
├── MEMORY_N8N_UPDATE.md
├── MEMORY_TEMPLATE_UPDATE.md
├── monitor_fetch.sh
├── N8N_HTTP_STREAMABLE_SETUP.md
├── n8n-nodes.db
├── P0-R3-TEST-PLAN.md
├── package-lock.json
├── package.json
├── package.runtime.json
├── PRIVACY.md
├── railway.json
├── README.md
├── renovate.json
├── scripts
│   ├── analyze-optimization.sh
│   ├── audit-schema-coverage.ts
│   ├── build-optimized.sh
│   ├── compare-benchmarks.js
│   ├── demo-optimization.sh
│   ├── deploy-http.sh
│   ├── deploy-to-vm.sh
│   ├── export-webhook-workflows.ts
│   ├── extract-changelog.js
│   ├── extract-from-docker.js
│   ├── extract-nodes-docker.sh
│   ├── extract-nodes-simple.sh
│   ├── format-benchmark-results.js
│   ├── generate-benchmark-stub.js
│   ├── generate-detailed-reports.js
│   ├── generate-test-summary.js
│   ├── http-bridge.js
│   ├── mcp-http-client.js
│   ├── migrate-nodes-fts.ts
│   ├── migrate-tool-docs.ts
│   ├── n8n-docs-mcp.service
│   ├── nginx-n8n-mcp.conf
│   ├── prebuild-fts5.ts
│   ├── prepare-release.js
│   ├── publish-npm-quick.sh
│   ├── publish-npm.sh
│   ├── quick-test.ts
│   ├── run-benchmarks-ci.js
│   ├── sync-runtime-version.js
│   ├── test-ai-validation-debug.ts
│   ├── test-code-node-enhancements.ts
│   ├── test-code-node-fixes.ts
│   ├── test-docker-config.sh
│   ├── test-docker-fingerprint.ts
│   ├── test-docker-optimization.sh
│   ├── test-docker.sh
│   ├── test-empty-connection-validation.ts
│   ├── test-error-message-tracking.ts
│   ├── test-error-output-validation.ts
│   ├── test-error-validation.js
│   ├── test-essentials.ts
│   ├── test-expression-code-validation.ts
│   ├── test-expression-format-validation.js
│   ├── test-fts5-search.ts
│   ├── test-fuzzy-fix.ts
│   ├── test-fuzzy-simple.ts
│   ├── test-helpers-validation.ts
│   ├── test-http-search.ts
│   ├── test-http.sh
│   ├── test-jmespath-validation.ts
│   ├── test-multi-tenant-simple.ts
│   ├── test-multi-tenant.ts
│   ├── test-n8n-integration.sh
│   ├── test-node-info.js
│   ├── test-node-type-validation.ts
│   ├── test-nodes-base-prefix.ts
│   ├── test-operation-validation.ts
│   ├── test-optimized-docker.sh
│   ├── test-release-automation.js
│   ├── test-search-improvements.ts
│   ├── test-security.ts
│   ├── test-single-session.sh
│   ├── test-sqljs-triggers.ts
│   ├── test-telemetry-debug.ts
│   ├── test-telemetry-direct.ts
│   ├── test-telemetry-env.ts
│   ├── test-telemetry-integration.ts
│   ├── test-telemetry-no-select.ts
│   ├── test-telemetry-security.ts
│   ├── test-telemetry-simple.ts
│   ├── test-typeversion-validation.ts
│   ├── test-url-configuration.ts
│   ├── test-user-id-persistence.ts
│   ├── test-webhook-validation.ts
│   ├── test-workflow-insert.ts
│   ├── test-workflow-sanitizer.ts
│   ├── test-workflow-tracking-debug.ts
│   ├── update-and-publish-prep.sh
│   ├── update-n8n-deps.js
│   ├── update-readme-version.js
│   ├── vitest-benchmark-json-reporter.js
│   └── vitest-benchmark-reporter.ts
├── SECURITY.md
├── src
│   ├── config
│   │   └── n8n-api.ts
│   ├── data
│   │   └── canonical-ai-tool-examples.json
│   ├── database
│   │   ├── database-adapter.ts
│   │   ├── migrations
│   │   │   └── add-template-node-configs.sql
│   │   ├── node-repository.ts
│   │   ├── nodes.db
│   │   ├── schema-optimized.sql
│   │   └── schema.sql
│   ├── errors
│   │   └── validation-service-error.ts
│   ├── http-server-single-session.ts
│   ├── http-server.ts
│   ├── index.ts
│   ├── loaders
│   │   └── node-loader.ts
│   ├── mappers
│   │   └── docs-mapper.ts
│   ├── mcp
│   │   ├── handlers-n8n-manager.ts
│   │   ├── handlers-workflow-diff.ts
│   │   ├── index.ts
│   │   ├── server.ts
│   │   ├── stdio-wrapper.ts
│   │   ├── tool-docs
│   │   │   ├── configuration
│   │   │   │   ├── get-node-as-tool-info.ts
│   │   │   │   ├── get-node-documentation.ts
│   │   │   │   ├── get-node-essentials.ts
│   │   │   │   ├── get-node-info.ts
│   │   │   │   ├── get-property-dependencies.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── search-node-properties.ts
│   │   │   ├── discovery
│   │   │   │   ├── get-database-statistics.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── list-ai-tools.ts
│   │   │   │   ├── list-nodes.ts
│   │   │   │   └── search-nodes.ts
│   │   │   ├── guides
│   │   │   │   ├── ai-agents-guide.ts
│   │   │   │   └── index.ts
│   │   │   ├── index.ts
│   │   │   ├── system
│   │   │   │   ├── index.ts
│   │   │   │   ├── n8n-diagnostic.ts
│   │   │   │   ├── n8n-health-check.ts
│   │   │   │   ├── n8n-list-available-tools.ts
│   │   │   │   └── tools-documentation.ts
│   │   │   ├── templates
│   │   │   │   ├── get-template.ts
│   │   │   │   ├── get-templates-for-task.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── list-node-templates.ts
│   │   │   │   ├── list-tasks.ts
│   │   │   │   ├── search-templates-by-metadata.ts
│   │   │   │   └── search-templates.ts
│   │   │   ├── types.ts
│   │   │   ├── validation
│   │   │   │   ├── index.ts
│   │   │   │   ├── validate-node-minimal.ts
│   │   │   │   ├── validate-node-operation.ts
│   │   │   │   ├── validate-workflow-connections.ts
│   │   │   │   ├── validate-workflow-expressions.ts
│   │   │   │   └── validate-workflow.ts
│   │   │   └── workflow_management
│   │   │       ├── index.ts
│   │   │       ├── n8n-autofix-workflow.ts
│   │   │       ├── n8n-create-workflow.ts
│   │   │       ├── n8n-delete-execution.ts
│   │   │       ├── n8n-delete-workflow.ts
│   │   │       ├── n8n-get-execution.ts
│   │   │       ├── n8n-get-workflow-details.ts
│   │   │       ├── n8n-get-workflow-minimal.ts
│   │   │       ├── n8n-get-workflow-structure.ts
│   │   │       ├── n8n-get-workflow.ts
│   │   │       ├── n8n-list-executions.ts
│   │   │       ├── n8n-list-workflows.ts
│   │   │       ├── n8n-trigger-webhook-workflow.ts
│   │   │       ├── n8n-update-full-workflow.ts
│   │   │       ├── n8n-update-partial-workflow.ts
│   │   │       └── n8n-validate-workflow.ts
│   │   ├── tools-documentation.ts
│   │   ├── tools-n8n-friendly.ts
│   │   ├── tools-n8n-manager.ts
│   │   ├── tools.ts
│   │   └── workflow-examples.ts
│   ├── mcp-engine.ts
│   ├── mcp-tools-engine.ts
│   ├── n8n
│   │   ├── MCPApi.credentials.ts
│   │   └── MCPNode.node.ts
│   ├── parsers
│   │   ├── node-parser.ts
│   │   ├── property-extractor.ts
│   │   └── simple-parser.ts
│   ├── scripts
│   │   ├── debug-http-search.ts
│   │   ├── extract-from-docker.ts
│   │   ├── fetch-templates-robust.ts
│   │   ├── fetch-templates.ts
│   │   ├── rebuild-database.ts
│   │   ├── rebuild-optimized.ts
│   │   ├── rebuild.ts
│   │   ├── sanitize-templates.ts
│   │   ├── seed-canonical-ai-examples.ts
│   │   ├── test-autofix-documentation.ts
│   │   ├── test-autofix-workflow.ts
│   │   ├── test-execution-filtering.ts
│   │   ├── test-node-suggestions.ts
│   │   ├── test-protocol-negotiation.ts
│   │   ├── test-summary.ts
│   │   ├── test-webhook-autofix.ts
│   │   ├── validate.ts
│   │   └── validation-summary.ts
│   ├── services
│   │   ├── ai-node-validator.ts
│   │   ├── ai-tool-validators.ts
│   │   ├── confidence-scorer.ts
│   │   ├── config-validator.ts
│   │   ├── enhanced-config-validator.ts
│   │   ├── example-generator.ts
│   │   ├── execution-processor.ts
│   │   ├── expression-format-validator.ts
│   │   ├── expression-validator.ts
│   │   ├── n8n-api-client.ts
│   │   ├── n8n-validation.ts
│   │   ├── node-documentation-service.ts
│   │   ├── node-sanitizer.ts
│   │   ├── node-similarity-service.ts
│   │   ├── node-specific-validators.ts
│   │   ├── operation-similarity-service.ts
│   │   ├── property-dependencies.ts
│   │   ├── property-filter.ts
│   │   ├── resource-similarity-service.ts
│   │   ├── sqlite-storage-service.ts
│   │   ├── task-templates.ts
│   │   ├── universal-expression-validator.ts
│   │   ├── workflow-auto-fixer.ts
│   │   ├── workflow-diff-engine.ts
│   │   └── workflow-validator.ts
│   ├── telemetry
│   │   ├── batch-processor.ts
│   │   ├── config-manager.ts
│   │   ├── early-error-logger.ts
│   │   ├── error-sanitization-utils.ts
│   │   ├── error-sanitizer.ts
│   │   ├── event-tracker.ts
│   │   ├── event-validator.ts
│   │   ├── index.ts
│   │   ├── performance-monitor.ts
│   │   ├── rate-limiter.ts
│   │   ├── startup-checkpoints.ts
│   │   ├── telemetry-error.ts
│   │   ├── telemetry-manager.ts
│   │   ├── telemetry-types.ts
│   │   └── workflow-sanitizer.ts
│   ├── templates
│   │   ├── batch-processor.ts
│   │   ├── metadata-generator.ts
│   │   ├── README.md
│   │   ├── template-fetcher.ts
│   │   ├── template-repository.ts
│   │   └── template-service.ts
│   ├── types
│   │   ├── index.ts
│   │   ├── instance-context.ts
│   │   ├── n8n-api.ts
│   │   ├── node-types.ts
│   │   └── workflow-diff.ts
│   └── utils
│       ├── auth.ts
│       ├── bridge.ts
│       ├── cache-utils.ts
│       ├── console-manager.ts
│       ├── documentation-fetcher.ts
│       ├── enhanced-documentation-fetcher.ts
│       ├── error-handler.ts
│       ├── example-generator.ts
│       ├── fixed-collection-validator.ts
│       ├── logger.ts
│       ├── mcp-client.ts
│       ├── n8n-errors.ts
│       ├── node-source-extractor.ts
│       ├── node-type-normalizer.ts
│       ├── node-type-utils.ts
│       ├── node-utils.ts
│       ├── npm-version-checker.ts
│       ├── protocol-version.ts
│       ├── simple-cache.ts
│       ├── ssrf-protection.ts
│       ├── template-node-resolver.ts
│       ├── template-sanitizer.ts
│       ├── url-detector.ts
│       ├── validation-schemas.ts
│       └── version.ts
├── test-output.txt
├── test-reinit-fix.sh
├── tests
│   ├── __snapshots__
│   │   └── .gitkeep
│   ├── auth.test.ts
│   ├── benchmarks
│   │   ├── database-queries.bench.ts
│   │   ├── index.ts
│   │   ├── mcp-tools.bench.ts
│   │   ├── mcp-tools.bench.ts.disabled
│   │   ├── mcp-tools.bench.ts.skip
│   │   ├── node-loading.bench.ts.disabled
│   │   ├── README.md
│   │   ├── search-operations.bench.ts.disabled
│   │   └── validation-performance.bench.ts.disabled
│   ├── bridge.test.ts
│   ├── comprehensive-extraction-test.js
│   ├── data
│   │   └── .gitkeep
│   ├── debug-slack-doc.js
│   ├── demo-enhanced-documentation.js
│   ├── docker-tests-README.md
│   ├── error-handler.test.ts
│   ├── examples
│   │   └── using-database-utils.test.ts
│   ├── extracted-nodes-db
│   │   ├── database-import.json
│   │   ├── extraction-report.json
│   │   ├── insert-nodes.sql
│   │   ├── n8n-nodes-base__Airtable.json
│   │   ├── n8n-nodes-base__Discord.json
│   │   ├── n8n-nodes-base__Function.json
│   │   ├── n8n-nodes-base__HttpRequest.json
│   │   ├── n8n-nodes-base__If.json
│   │   ├── n8n-nodes-base__Slack.json
│   │   ├── n8n-nodes-base__SplitInBatches.json
│   │   └── n8n-nodes-base__Webhook.json
│   ├── factories
│   │   ├── node-factory.ts
│   │   └── property-definition-factory.ts
│   ├── fixtures
│   │   ├── .gitkeep
│   │   ├── database
│   │   │   └── test-nodes.json
│   │   ├── factories
│   │   │   ├── node.factory.ts
│   │   │   └── parser-node.factory.ts
│   │   └── template-configs.ts
│   ├── helpers
│   │   └── env-helpers.ts
│   ├── http-server-auth.test.ts
│   ├── integration
│   │   ├── ai-validation
│   │   │   ├── ai-agent-validation.test.ts
│   │   │   ├── ai-tool-validation.test.ts
│   │   │   ├── chat-trigger-validation.test.ts
│   │   │   ├── e2e-validation.test.ts
│   │   │   ├── helpers.ts
│   │   │   ├── llm-chain-validation.test.ts
│   │   │   ├── README.md
│   │   │   └── TEST_REPORT.md
│   │   ├── ci
│   │   │   └── database-population.test.ts
│   │   ├── database
│   │   │   ├── connection-management.test.ts
│   │   │   ├── empty-database.test.ts
│   │   │   ├── fts5-search.test.ts
│   │   │   ├── node-fts5-search.test.ts
│   │   │   ├── node-repository.test.ts
│   │   │   ├── performance.test.ts
│   │   │   ├── sqljs-memory-leak.test.ts
│   │   │   ├── template-node-configs.test.ts
│   │   │   ├── template-repository.test.ts
│   │   │   ├── test-utils.ts
│   │   │   └── transactions.test.ts
│   │   ├── database-integration.test.ts
│   │   ├── docker
│   │   │   ├── docker-config.test.ts
│   │   │   ├── docker-entrypoint.test.ts
│   │   │   └── test-helpers.ts
│   │   ├── flexible-instance-config.test.ts
│   │   ├── mcp
│   │   │   └── template-examples-e2e.test.ts
│   │   ├── mcp-protocol
│   │   │   ├── basic-connection.test.ts
│   │   │   ├── error-handling.test.ts
│   │   │   ├── performance.test.ts
│   │   │   ├── protocol-compliance.test.ts
│   │   │   ├── README.md
│   │   │   ├── session-management.test.ts
│   │   │   ├── test-helpers.ts
│   │   │   ├── tool-invocation.test.ts
│   │   │   └── workflow-error-validation.test.ts
│   │   ├── msw-setup.test.ts
│   │   ├── n8n-api
│   │   │   ├── executions
│   │   │   │   ├── delete-execution.test.ts
│   │   │   │   ├── get-execution.test.ts
│   │   │   │   ├── list-executions.test.ts
│   │   │   │   └── trigger-webhook.test.ts
│   │   │   ├── scripts
│   │   │   │   └── cleanup-orphans.ts
│   │   │   ├── system
│   │   │   │   ├── diagnostic.test.ts
│   │   │   │   ├── health-check.test.ts
│   │   │   │   └── list-tools.test.ts
│   │   │   ├── test-connection.ts
│   │   │   ├── types
│   │   │   │   └── mcp-responses.ts
│   │   │   ├── utils
│   │   │   │   ├── cleanup-helpers.ts
│   │   │   │   ├── credentials.ts
│   │   │   │   ├── factories.ts
│   │   │   │   ├── fixtures.ts
│   │   │   │   ├── mcp-context.ts
│   │   │   │   ├── n8n-client.ts
│   │   │   │   ├── node-repository.ts
│   │   │   │   ├── response-types.ts
│   │   │   │   ├── test-context.ts
│   │   │   │   └── webhook-workflows.ts
│   │   │   └── workflows
│   │   │       ├── autofix-workflow.test.ts
│   │   │       ├── create-workflow.test.ts
│   │   │       ├── delete-workflow.test.ts
│   │   │       ├── get-workflow-details.test.ts
│   │   │       ├── get-workflow-minimal.test.ts
│   │   │       ├── get-workflow-structure.test.ts
│   │   │       ├── get-workflow.test.ts
│   │   │       ├── list-workflows.test.ts
│   │   │       ├── smart-parameters.test.ts
│   │   │       ├── update-partial-workflow.test.ts
│   │   │       ├── update-workflow.test.ts
│   │   │       └── validate-workflow.test.ts
│   │   ├── security
│   │   │   ├── command-injection-prevention.test.ts
│   │   │   └── rate-limiting.test.ts
│   │   ├── setup
│   │   │   ├── integration-setup.ts
│   │   │   └── msw-test-server.ts
│   │   ├── telemetry
│   │   │   ├── docker-user-id-stability.test.ts
│   │   │   └── mcp-telemetry.test.ts
│   │   ├── templates
│   │   │   └── metadata-operations.test.ts
│   │   └── workflow-creation-node-type-format.test.ts
│   ├── logger.test.ts
│   ├── MOCKING_STRATEGY.md
│   ├── mocks
│   │   ├── n8n-api
│   │   │   ├── data
│   │   │   │   ├── credentials.ts
│   │   │   │   ├── executions.ts
│   │   │   │   └── workflows.ts
│   │   │   ├── handlers.ts
│   │   │   └── index.ts
│   │   └── README.md
│   ├── node-storage-export.json
│   ├── setup
│   │   ├── global-setup.ts
│   │   ├── msw-setup.ts
│   │   ├── TEST_ENV_DOCUMENTATION.md
│   │   └── test-env.ts
│   ├── test-database-extraction.js
│   ├── test-direct-extraction.js
│   ├── test-enhanced-documentation.js
│   ├── test-enhanced-integration.js
│   ├── test-mcp-extraction.js
│   ├── test-mcp-server-extraction.js
│   ├── test-mcp-tools-integration.js
│   ├── test-node-documentation-service.js
│   ├── test-node-list.js
│   ├── test-package-info.js
│   ├── test-parsing-operations.js
│   ├── test-slack-node-complete.js
│   ├── test-small-rebuild.js
│   ├── test-sqlite-search.js
│   ├── test-storage-system.js
│   ├── unit
│   │   ├── __mocks__
│   │   │   ├── n8n-nodes-base.test.ts
│   │   │   ├── n8n-nodes-base.ts
│   │   │   └── README.md
│   │   ├── database
│   │   │   ├── __mocks__
│   │   │   │   └── better-sqlite3.ts
│   │   │   ├── database-adapter-unit.test.ts
│   │   │   ├── node-repository-core.test.ts
│   │   │   ├── node-repository-operations.test.ts
│   │   │   ├── node-repository-outputs.test.ts
│   │   │   ├── README.md
│   │   │   └── template-repository-core.test.ts
│   │   ├── docker
│   │   │   ├── config-security.test.ts
│   │   │   ├── edge-cases.test.ts
│   │   │   ├── parse-config.test.ts
│   │   │   └── serve-command.test.ts
│   │   ├── errors
│   │   │   └── validation-service-error.test.ts
│   │   ├── examples
│   │   │   └── using-n8n-nodes-base-mock.test.ts
│   │   ├── flexible-instance-security-advanced.test.ts
│   │   ├── flexible-instance-security.test.ts
│   │   ├── http-server
│   │   │   └── multi-tenant-support.test.ts
│   │   ├── http-server-n8n-mode.test.ts
│   │   ├── http-server-n8n-reinit.test.ts
│   │   ├── http-server-session-management.test.ts
│   │   ├── loaders
│   │   │   └── node-loader.test.ts
│   │   ├── mappers
│   │   │   └── docs-mapper.test.ts
│   │   ├── mcp
│   │   │   ├── get-node-essentials-examples.test.ts
│   │   │   ├── handlers-n8n-manager-simple.test.ts
│   │   │   ├── handlers-n8n-manager.test.ts
│   │   │   ├── handlers-workflow-diff.test.ts
│   │   │   ├── lru-cache-behavior.test.ts
│   │   │   ├── multi-tenant-tool-listing.test.ts.disabled
│   │   │   ├── parameter-validation.test.ts
│   │   │   ├── search-nodes-examples.test.ts
│   │   │   ├── tools-documentation.test.ts
│   │   │   └── tools.test.ts
│   │   ├── monitoring
│   │   │   └── cache-metrics.test.ts
│   │   ├── MULTI_TENANT_TEST_COVERAGE.md
│   │   ├── multi-tenant-integration.test.ts
│   │   ├── parsers
│   │   │   ├── node-parser-outputs.test.ts
│   │   │   ├── node-parser.test.ts
│   │   │   ├── property-extractor.test.ts
│   │   │   └── simple-parser.test.ts
│   │   ├── scripts
│   │   │   └── fetch-templates-extraction.test.ts
│   │   ├── services
│   │   │   ├── ai-node-validator.test.ts
│   │   │   ├── ai-tool-validators.test.ts
│   │   │   ├── confidence-scorer.test.ts
│   │   │   ├── config-validator-basic.test.ts
│   │   │   ├── config-validator-edge-cases.test.ts
│   │   │   ├── config-validator-node-specific.test.ts
│   │   │   ├── config-validator-security.test.ts
│   │   │   ├── debug-validator.test.ts
│   │   │   ├── enhanced-config-validator-integration.test.ts
│   │   │   ├── enhanced-config-validator-operations.test.ts
│   │   │   ├── enhanced-config-validator.test.ts
│   │   │   ├── example-generator.test.ts
│   │   │   ├── execution-processor.test.ts
│   │   │   ├── expression-format-validator.test.ts
│   │   │   ├── expression-validator-edge-cases.test.ts
│   │   │   ├── expression-validator.test.ts
│   │   │   ├── fixed-collection-validation.test.ts
│   │   │   ├── loop-output-edge-cases.test.ts
│   │   │   ├── n8n-api-client.test.ts
│   │   │   ├── n8n-validation.test.ts
│   │   │   ├── node-sanitizer.test.ts
│   │   │   ├── node-similarity-service.test.ts
│   │   │   ├── node-specific-validators.test.ts
│   │   │   ├── operation-similarity-service-comprehensive.test.ts
│   │   │   ├── operation-similarity-service.test.ts
│   │   │   ├── property-dependencies.test.ts
│   │   │   ├── property-filter-edge-cases.test.ts
│   │   │   ├── property-filter.test.ts
│   │   │   ├── resource-similarity-service-comprehensive.test.ts
│   │   │   ├── resource-similarity-service.test.ts
│   │   │   ├── task-templates.test.ts
│   │   │   ├── template-service.test.ts
│   │   │   ├── universal-expression-validator.test.ts
│   │   │   ├── validation-fixes.test.ts
│   │   │   ├── workflow-auto-fixer.test.ts
│   │   │   ├── workflow-diff-engine.test.ts
│   │   │   ├── workflow-fixed-collection-validation.test.ts
│   │   │   ├── workflow-validator-comprehensive.test.ts
│   │   │   ├── workflow-validator-edge-cases.test.ts
│   │   │   ├── workflow-validator-error-outputs.test.ts
│   │   │   ├── workflow-validator-expression-format.test.ts
│   │   │   ├── workflow-validator-loops-simple.test.ts
│   │   │   ├── workflow-validator-loops.test.ts
│   │   │   ├── workflow-validator-mocks.test.ts
│   │   │   ├── workflow-validator-performance.test.ts
│   │   │   ├── workflow-validator-with-mocks.test.ts
│   │   │   └── workflow-validator.test.ts
│   │   ├── telemetry
│   │   │   ├── batch-processor.test.ts
│   │   │   ├── config-manager.test.ts
│   │   │   ├── event-tracker.test.ts
│   │   │   ├── event-validator.test.ts
│   │   │   ├── rate-limiter.test.ts
│   │   │   ├── telemetry-error.test.ts
│   │   │   ├── telemetry-manager.test.ts
│   │   │   ├── v2.18.3-fixes-verification.test.ts
│   │   │   └── workflow-sanitizer.test.ts
│   │   ├── templates
│   │   │   ├── batch-processor.test.ts
│   │   │   ├── metadata-generator.test.ts
│   │   │   ├── template-repository-metadata.test.ts
│   │   │   └── template-repository-security.test.ts
│   │   ├── test-env-example.test.ts
│   │   ├── test-infrastructure.test.ts
│   │   ├── types
│   │   │   ├── instance-context-coverage.test.ts
│   │   │   └── instance-context-multi-tenant.test.ts
│   │   ├── utils
│   │   │   ├── auth-timing-safe.test.ts
│   │   │   ├── cache-utils.test.ts
│   │   │   ├── console-manager.test.ts
│   │   │   ├── database-utils.test.ts
│   │   │   ├── fixed-collection-validator.test.ts
│   │   │   ├── n8n-errors.test.ts
│   │   │   ├── node-type-normalizer.test.ts
│   │   │   ├── node-type-utils.test.ts
│   │   │   ├── node-utils.test.ts
│   │   │   ├── simple-cache-memory-leak-fix.test.ts
│   │   │   ├── ssrf-protection.test.ts
│   │   │   └── template-node-resolver.test.ts
│   │   └── validation-fixes.test.ts
│   └── utils
│       ├── assertions.ts
│       ├── builders
│       │   └── workflow.builder.ts
│       ├── data-generators.ts
│       ├── database-utils.ts
│       ├── README.md
│       └── test-helpers.ts
├── thumbnail.png
├── tsconfig.build.json
├── tsconfig.json
├── types
│   ├── mcp.d.ts
│   └── test-env.d.ts
├── verify-telemetry-fix.js
├── versioned-nodes.md
├── vitest.config.benchmark.ts
├── vitest.config.integration.ts
└── vitest.config.ts
```

# Files

--------------------------------------------------------------------------------
/tests/integration/templates/metadata-operations.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  2 | import { TemplateService } from '../../../src/templates/template-service';
  3 | import { TemplateRepository } from '../../../src/templates/template-repository';
  4 | import { MetadataGenerator } from '../../../src/templates/metadata-generator';
  5 | import { BatchProcessor } from '../../../src/templates/batch-processor';
  6 | import { DatabaseAdapter, createDatabaseAdapter } from '../../../src/database/database-adapter';
  7 | import { tmpdir } from 'os';
  8 | import * as path from 'path';
  9 | import { unlinkSync, existsSync, readFileSync } from 'fs';
 10 | 
 11 | // Mock logger
 12 | vi.mock('../../../src/utils/logger', () => ({
 13 |   logger: {
 14 |     info: vi.fn(),
 15 |     warn: vi.fn(),
 16 |     error: vi.fn(),
 17 |     debug: vi.fn()
 18 |   }
 19 | }));
 20 | 
 21 | // Mock template sanitizer
 22 | vi.mock('../../../src/utils/template-sanitizer', () => {
 23 |   class MockTemplateSanitizer {
 24 |     sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false }));
 25 |     detectTokens = vi.fn(() => []);
 26 |   }
 27 |   
 28 |   return {
 29 |     TemplateSanitizer: MockTemplateSanitizer
 30 |   };
 31 | });
 32 | 
 33 | // Mock OpenAI for MetadataGenerator and BatchProcessor
 34 | vi.mock('openai', () => {
 35 |   const mockClient = {
 36 |     chat: {
 37 |       completions: {
 38 |         create: vi.fn()
 39 |       }
 40 |     },
 41 |     files: {
 42 |       create: vi.fn(),
 43 |       content: vi.fn(),
 44 |       del: vi.fn()
 45 |     },
 46 |     batches: {
 47 |       create: vi.fn(),
 48 |       retrieve: vi.fn()
 49 |     }
 50 |   };
 51 | 
 52 |   return {
 53 |     default: vi.fn().mockImplementation(() => mockClient)
 54 |   };
 55 | });
 56 | 
 57 | describe('Template Metadata Operations - Integration Tests', () => {
 58 |   let adapter: DatabaseAdapter;
 59 |   let repository: TemplateRepository;
 60 |   let service: TemplateService;
 61 |   let dbPath: string;
 62 | 
 63 |   beforeEach(async () => {
 64 |     // Create temporary database
 65 |     dbPath = path.join(tmpdir(), `test-metadata-${Date.now()}.db`);
 66 |     adapter = await createDatabaseAdapter(dbPath);
 67 |     
 68 |     // Initialize database schema
 69 |     const schemaPath = path.join(__dirname, '../../../src/database/schema.sql');
 70 |     const schema = readFileSync(schemaPath, 'utf8');
 71 |     adapter.exec(schema);
 72 |     
 73 |     // Initialize repository and service
 74 |     repository = new TemplateRepository(adapter);
 75 |     service = new TemplateService(adapter);
 76 | 
 77 |     // Create test templates
 78 |     await createTestTemplates();
 79 |   });
 80 | 
 81 |   afterEach(() => {
 82 |     if (adapter) {
 83 |       adapter.close();
 84 |     }
 85 |     if (existsSync(dbPath)) {
 86 |       unlinkSync(dbPath);
 87 |     }
 88 |     vi.clearAllMocks();
 89 |   });
 90 | 
 91 |   async function createTestTemplates() {
 92 |     // Create test templates with metadata
 93 |     const templates = [
 94 |       {
 95 |         workflow: {
 96 |           id: 1,
 97 |           name: 'Simple Webhook Slack',
 98 |           description: 'Basic webhook to Slack automation',
 99 |           user: { id: 1, name: 'Test User', username: 'test', verified: true },
100 |           nodes: [
101 |             { id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' },
102 |             { id: 2, name: 'n8n-nodes-base.slack', icon: 'fa:slack' }
103 |           ],
104 |           totalViews: 150,
105 |           createdAt: '2024-01-01T00:00:00Z'
106 |         },
107 |         detail: {
108 |           id: 1,
109 |           name: 'Simple Webhook Slack',
110 |           description: 'Basic webhook to Slack automation',
111 |           views: 150,
112 |           createdAt: '2024-01-01T00:00:00Z',
113 |           workflow: {
114 |             nodes: [
115 |               { type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 },
116 |               { type: 'n8n-nodes-base.slack', name: 'Slack', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 }
117 |             ],
118 |             connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } },
119 |             settings: {}
120 |           }
121 |         },
122 |         categories: ['automation', 'communication'],
123 |         metadata: {
124 |           categories: ['automation', 'communication'],
125 |           complexity: 'simple' as const,
126 |           use_cases: ['Webhook processing', 'Slack notifications'],
127 |           estimated_setup_minutes: 15,
128 |           required_services: ['Slack API'],
129 |           key_features: ['Real-time notifications', 'Easy setup'],
130 |           target_audience: ['developers', 'marketers']
131 |         }
132 |       },
133 |       {
134 |         workflow: {
135 |           id: 2,
136 |           name: 'Complex AI Data Pipeline',
137 |           description: 'Advanced data processing with AI analysis',
138 |           user: { id: 2, name: 'AI Expert', username: 'aiexpert', verified: true },
139 |           nodes: [
140 |             { id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' },
141 |             { id: 2, name: '@n8n/n8n-nodes-langchain.openAi', icon: 'fa:brain' },
142 |             { id: 3, name: 'n8n-nodes-base.postgres', icon: 'fa:database' },
143 |             { id: 4, name: 'n8n-nodes-base.googleSheets', icon: 'fa:sheet' }
144 |           ],
145 |           totalViews: 450,
146 |           createdAt: '2024-01-15T00:00:00Z'
147 |         },
148 |         detail: {
149 |           id: 2,
150 |           name: 'Complex AI Data Pipeline',
151 |           description: 'Advanced data processing with AI analysis',
152 |           views: 450,
153 |           createdAt: '2024-01-15T00:00:00Z',
154 |           workflow: {
155 |             nodes: [
156 |               { type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 },
157 |               { type: '@n8n/n8n-nodes-langchain.openAi', name: 'OpenAI', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 },
158 |               { type: 'n8n-nodes-base.postgres', name: 'Postgres', id: '3', position: [200, 0], parameters: {}, typeVersion: 1 },
159 |               { type: 'n8n-nodes-base.googleSheets', name: 'Google Sheets', id: '4', position: [300, 0], parameters: {}, typeVersion: 1 }
160 |             ],
161 |             connections: {
162 |               '1': { main: [[{ node: '2', type: 'main', index: 0 }]] },
163 |               '2': { main: [[{ node: '3', type: 'main', index: 0 }]] },
164 |               '3': { main: [[{ node: '4', type: 'main', index: 0 }]] }
165 |             },
166 |             settings: {}
167 |           }
168 |         },
169 |         categories: ['ai', 'data_processing'],
170 |         metadata: {
171 |           categories: ['ai', 'data_processing', 'automation'],
172 |           complexity: 'complex' as const,
173 |           use_cases: ['Data analysis', 'AI processing', 'Report generation'],
174 |           estimated_setup_minutes: 120,
175 |           required_services: ['OpenAI API', 'PostgreSQL', 'Google Sheets API'],
176 |           key_features: ['AI analysis', 'Database integration', 'Automated reports'],
177 |           target_audience: ['developers', 'analysts']
178 |         }
179 |       },
180 |       {
181 |         workflow: {
182 |           id: 3,
183 |           name: 'Medium Email Automation',
184 |           description: 'Email automation with moderate complexity',
185 |           user: { id: 3, name: 'Marketing User', username: 'marketing', verified: false },
186 |           nodes: [
187 |             { id: 1, name: 'n8n-nodes-base.cron', icon: 'fa:clock' },
188 |             { id: 2, name: 'n8n-nodes-base.gmail', icon: 'fa:mail' },
189 |             { id: 3, name: 'n8n-nodes-base.googleSheets', icon: 'fa:sheet' }
190 |           ],
191 |           totalViews: 200,
192 |           createdAt: '2024-02-01T00:00:00Z'
193 |         },
194 |         detail: {
195 |           id: 3,
196 |           name: 'Medium Email Automation',
197 |           description: 'Email automation with moderate complexity',
198 |           views: 200,
199 |           createdAt: '2024-02-01T00:00:00Z',
200 |           workflow: {
201 |             nodes: [
202 |               { type: 'n8n-nodes-base.cron', name: 'Cron', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 },
203 |               { type: 'n8n-nodes-base.gmail', name: 'Gmail', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 },
204 |               { type: 'n8n-nodes-base.googleSheets', name: 'Google Sheets', id: '3', position: [200, 0], parameters: {}, typeVersion: 1 }
205 |             ],
206 |             connections: {
207 |               '1': { main: [[{ node: '2', type: 'main', index: 0 }]] },
208 |               '2': { main: [[{ node: '3', type: 'main', index: 0 }]] }
209 |             },
210 |             settings: {}
211 |           }
212 |         },
213 |         categories: ['email_automation', 'scheduling'],
214 |         metadata: {
215 |           categories: ['email_automation', 'scheduling'],
216 |           complexity: 'medium' as const,
217 |           use_cases: ['Email campaigns', 'Scheduled reports'],
218 |           estimated_setup_minutes: 45,
219 |           required_services: ['Gmail API', 'Google Sheets API'],
220 |           key_features: ['Scheduled execution', 'Email automation'],
221 |           target_audience: ['marketers']
222 |         }
223 |       }
224 |     ];
225 | 
226 |     // Save templates
227 |     for (const template of templates) {
228 |       repository.saveTemplate(template.workflow, template.detail, template.categories);
229 |       repository.updateTemplateMetadata(template.workflow.id, template.metadata);
230 |     }
231 |   }
232 | 
233 |   describe('Repository Metadata Operations', () => {
234 |     it('should update template metadata successfully', () => {
235 |       const newMetadata = {
236 |         categories: ['test', 'updated'],
237 |         complexity: 'simple' as const,
238 |         use_cases: ['Testing'],
239 |         estimated_setup_minutes: 10,
240 |         required_services: [],
241 |         key_features: ['Test feature'],
242 |         target_audience: ['testers']
243 |       };
244 | 
245 |       repository.updateTemplateMetadata(1, newMetadata);
246 | 
247 |       // Verify metadata was updated
248 |       const templates = repository.searchTemplatesByMetadata({
249 |         category: 'test'}, 10, 0);
250 | 
251 |       expect(templates).toHaveLength(1);
252 |       expect(templates[0].id).toBe(1);
253 |     });
254 | 
255 |     it('should batch update metadata for multiple templates', () => {
256 |       const metadataMap = new Map([
257 |         [1, {
258 |           categories: ['batch_test'],
259 |           complexity: 'simple' as const,
260 |           use_cases: ['Batch testing'],
261 |           estimated_setup_minutes: 20,
262 |           required_services: [],
263 |           key_features: ['Batch update'],
264 |           target_audience: ['developers']
265 |         }],
266 |         [2, {
267 |           categories: ['batch_test'],
268 |           complexity: 'complex' as const,
269 |           use_cases: ['Complex batch testing'],
270 |           estimated_setup_minutes: 60,
271 |           required_services: ['OpenAI'],
272 |           key_features: ['Advanced batch'],
273 |           target_audience: ['developers']
274 |         }]
275 |       ]);
276 | 
277 |       repository.batchUpdateMetadata(metadataMap);
278 | 
279 |       // Verify both templates were updated
280 |       const templates = repository.searchTemplatesByMetadata({
281 |         category: 'batch_test'}, 10, 0);
282 | 
283 |       expect(templates).toHaveLength(2);
284 |       expect(templates.map(t => t.id).sort()).toEqual([1, 2]);
285 |     });
286 | 
287 |     it('should search templates by category', () => {
288 |       const templates = repository.searchTemplatesByMetadata({
289 |         category: 'automation'}, 10, 0);
290 | 
291 |       expect(templates.length).toBeGreaterThan(0);
292 |       expect(templates[0]).toHaveProperty('id');
293 |       expect(templates[0]).toHaveProperty('name');
294 |     });
295 | 
296 |     it('should search templates by complexity', () => {
297 |       const simpleTemplates = repository.searchTemplatesByMetadata({
298 |         complexity: 'simple'}, 10, 0);
299 | 
300 |       const complexTemplates = repository.searchTemplatesByMetadata({
301 |         complexity: 'complex'}, 10, 0);
302 | 
303 |       expect(simpleTemplates).toHaveLength(1);
304 |       expect(complexTemplates).toHaveLength(1);
305 |       expect(simpleTemplates[0].id).toBe(1);
306 |       expect(complexTemplates[0].id).toBe(2);
307 |     });
308 | 
309 |     it('should search templates by setup time', () => {
310 |       const quickTemplates = repository.searchTemplatesByMetadata({
311 |         maxSetupMinutes: 30}, 10, 0);
312 | 
313 |       const longTemplates = repository.searchTemplatesByMetadata({
314 |         minSetupMinutes: 60}, 10, 0);
315 | 
316 |       expect(quickTemplates).toHaveLength(1); // Only 15 min template (45 min > 30)
317 |       expect(longTemplates).toHaveLength(1); // 120 min template
318 |     });
319 | 
320 |     it('should search templates by required service', () => {
321 |       const slackTemplates = repository.searchTemplatesByMetadata({
322 |         requiredService: 'slack'}, 10, 0);
323 | 
324 |       const openaiTemplates = repository.searchTemplatesByMetadata({
325 |         requiredService: 'OpenAI'}, 10, 0);
326 | 
327 |       expect(slackTemplates).toHaveLength(1);
328 |       expect(openaiTemplates).toHaveLength(1);
329 |     });
330 | 
331 |     it('should search templates by target audience', () => {
332 |       const developerTemplates = repository.searchTemplatesByMetadata({
333 |         targetAudience: 'developers'}, 10, 0);
334 | 
335 |       const marketerTemplates = repository.searchTemplatesByMetadata({
336 |         targetAudience: 'marketers'}, 10, 0);
337 | 
338 |       expect(developerTemplates).toHaveLength(2);
339 |       expect(marketerTemplates).toHaveLength(2);
340 |     });
341 | 
342 |     it('should handle combined filters correctly', () => {
343 |       const filteredTemplates = repository.searchTemplatesByMetadata({
344 |         complexity: 'medium',
345 |         targetAudience: 'marketers',
346 |         maxSetupMinutes: 60}, 10, 0);
347 | 
348 |       expect(filteredTemplates).toHaveLength(1);
349 |       expect(filteredTemplates[0].id).toBe(3);
350 |     });
351 | 
352 |     it('should return correct counts for metadata searches', () => {
353 |       const automationCount = repository.getSearchTemplatesByMetadataCount({
354 |         category: 'automation'
355 |       });
356 | 
357 |       const complexCount = repository.getSearchTemplatesByMetadataCount({
358 |         complexity: 'complex'
359 |       });
360 | 
361 |       expect(automationCount).toBeGreaterThan(0);
362 |       expect(complexCount).toBe(1);
363 |     });
364 | 
365 |     it('should get unique categories', () => {
366 |       const categories = repository.getUniqueCategories();
367 |       
368 |       expect(categories).toContain('automation');
369 |       expect(categories).toContain('communication');
370 |       expect(categories).toContain('ai');
371 |       expect(categories).toContain('data_processing');
372 |       expect(categories).toContain('email_automation');
373 |       expect(categories).toContain('scheduling');
374 |     });
375 | 
376 |     it('should get unique target audiences', () => {
377 |       const audiences = repository.getUniqueTargetAudiences();
378 |       
379 |       expect(audiences).toContain('developers');
380 |       expect(audiences).toContain('marketers');
381 |       expect(audiences).toContain('analysts');
382 |     });
383 | 
384 |     it('should get templates by category', () => {
385 |       const aiTemplates = repository.getTemplatesByCategory('ai');
386 |       // Both template 2 has 'ai', and template 1 has 'automation' which contains 'ai' as substring
387 |       // due to LIKE '%ai%' matching
388 |       expect(aiTemplates).toHaveLength(2);
389 |       // Template 2 should be first due to higher view count (450 vs 150)
390 |       expect(aiTemplates[0].id).toBe(2);
391 |     });
392 | 
393 |     it('should get templates by complexity', () => {
394 |       const simpleTemplates = repository.getTemplatesByComplexity('simple');
395 |       expect(simpleTemplates).toHaveLength(1);
396 |       expect(simpleTemplates[0].id).toBe(1);
397 |     });
398 | 
399 |     it('should get templates without metadata', () => {
400 |       // Create a template without metadata
401 |       const workflow = {
402 |         id: 999,
403 |         name: 'No Metadata Template',
404 |         description: 'Template without metadata',
405 |         user: { id: 999, name: 'Test', username: 'test', verified: true },
406 |         nodes: [{ id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' }],
407 |         totalViews: 50, // Must be > 10 to not be filtered out
408 |         createdAt: '2024-03-01T00:00:00Z'
409 |       };
410 | 
411 |       const detail = {
412 |         id: 999,
413 |         name: 'No Metadata Template',
414 |         description: 'Template without metadata',
415 |         views: 50, // Must be > 10 to not be filtered out
416 |         createdAt: '2024-03-01T00:00:00Z',
417 |         workflow: {
418 |           nodes: [{ type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }],
419 |           connections: {},
420 |           settings: {}
421 |         }
422 |       };
423 | 
424 |       repository.saveTemplate(workflow, detail, []);
425 |       // Don't update metadata for this template, so it remains without metadata
426 | 
427 |       const templatesWithoutMetadata = repository.getTemplatesWithoutMetadata();
428 |       expect(templatesWithoutMetadata.some(t => t.workflow_id === 999)).toBe(true);
429 |     });
430 | 
431 |     it('should get outdated metadata templates', () => {
432 |       // This test would require manipulating timestamps, 
433 |       // for now just verify the method doesn't throw
434 |       const outdatedTemplates = repository.getTemplatesWithOutdatedMetadata(30);
435 |       expect(Array.isArray(outdatedTemplates)).toBe(true);
436 |     });
437 | 
438 |     it('should get metadata statistics', () => {
439 |       const stats = repository.getMetadataStats();
440 |       
441 |       expect(stats).toHaveProperty('withMetadata');
442 |       expect(stats).toHaveProperty('total');
443 |       expect(stats).toHaveProperty('withoutMetadata');
444 |       expect(stats).toHaveProperty('outdated');
445 | 
446 |       expect(stats.withMetadata).toBeGreaterThan(0);
447 |       expect(stats.total).toBeGreaterThan(0);
448 |     });
449 |   });
450 | 
451 |   describe('Service Layer Integration', () => {
452 |     it('should search templates with metadata through service', async () => {
453 |       const results = await service.searchTemplatesByMetadata({
454 |         complexity: 'simple'}, 10, 0);
455 | 
456 |       expect(results).toHaveProperty('items');
457 |       expect(results).toHaveProperty('total');
458 |       expect(results).toHaveProperty('hasMore');
459 |       expect(results.items.length).toBeGreaterThan(0);
460 |       expect(results.items[0]).toHaveProperty('metadata');
461 |     });
462 | 
463 |     it('should handle pagination correctly in metadata search', async () => {
464 |       const page1 = await service.searchTemplatesByMetadata(
465 |         {}, // empty filters
466 |         1,  // limit
467 |         0   // offset
468 |       );
469 | 
470 |       const page2 = await service.searchTemplatesByMetadata(
471 |         {}, // empty filters
472 |         1,  // limit
473 |         1   // offset
474 |       );
475 | 
476 |       expect(page1.items).toHaveLength(1);
477 |       expect(page2.items).toHaveLength(1);
478 |       expect(page1.items[0].id).not.toBe(page2.items[0].id);
479 |     });
480 | 
481 |     it('should return templates with metadata information', async () => {
482 |       const results = await service.searchTemplatesByMetadata({
483 |         category: 'automation'}, 10, 0);
484 | 
485 |       expect(results.items.length).toBeGreaterThan(0);
486 |       
487 |       const template = results.items[0];
488 |       expect(template).toHaveProperty('metadata');
489 |       expect(template.metadata).toHaveProperty('categories');
490 |       expect(template.metadata).toHaveProperty('complexity');
491 |       expect(template.metadata).toHaveProperty('estimated_setup_minutes');
492 |     });
493 |   });
494 | 
495 |   describe('Security and Error Handling', () => {
496 |     it('should handle malicious input safely in metadata search', () => {
497 |       const maliciousInputs = [
498 |         { category: "'; DROP TABLE templates; --" },
499 |         { requiredService: "'; UNION SELECT * FROM sqlite_master; --" },
500 |         { targetAudience: "administrators'; DELETE FROM templates WHERE '1'='1" }
501 |       ];
502 | 
503 |       maliciousInputs.forEach(input => {
504 |         expect(() => {
505 |           repository.searchTemplatesByMetadata({
506 |             ...input}, 10, 0);
507 |         }).not.toThrow();
508 |       });
509 |     });
510 | 
511 |     it('should handle invalid metadata gracefully', () => {
512 |       const invalidMetadata = {
513 |         categories: null,
514 |         complexity: 'invalid_complexity',
515 |         use_cases: 'not_an_array',
516 |         estimated_setup_minutes: 'not_a_number',
517 |         required_services: undefined,
518 |         key_features: {},
519 |         target_audience: 42
520 |       };
521 | 
522 |       expect(() => {
523 |         repository.updateTemplateMetadata(1, invalidMetadata);
524 |       }).not.toThrow();
525 |     });
526 | 
527 |     it('should handle empty search results gracefully', () => {
528 |       const results = repository.searchTemplatesByMetadata({
529 |         category: 'nonexistent_category'}, 10, 0);
530 | 
531 |       expect(results).toHaveLength(0);
532 |     });
533 | 
534 |     it('should handle edge case parameters', () => {
535 |       // Test extreme values
536 |       const results = repository.searchTemplatesByMetadata({
537 |         maxSetupMinutes: 0,
538 |         minSetupMinutes: 999999
539 |       }, 0, -1); // offset -1 to test edge case
540 | 
541 |       expect(Array.isArray(results)).toBe(true);
542 |     });
543 |   });
544 | 
545 |   describe('Performance and Scalability', () => {
546 |     it('should handle large result sets efficiently', () => {
547 |       // Test with maximum limit
548 |       const startTime = Date.now();
549 |       const results = repository.searchTemplatesByMetadata({}, 100, 0);
550 |       const endTime = Date.now();
551 | 
552 |       expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second
553 |       expect(Array.isArray(results)).toBe(true);
554 |     });
555 | 
556 |     it('should handle concurrent metadata updates', () => {
557 |       const updates: any[] = [];
558 |       
559 |       for (let i = 0; i < 10; i++) {
560 |         updates.push(() => {
561 |           repository.updateTemplateMetadata(1, {
562 |             categories: [`concurrent_test_${i}`],
563 |             complexity: 'simple' as const,
564 |             use_cases: ['Testing'],
565 |             estimated_setup_minutes: 10,
566 |             required_services: [],
567 |             key_features: ['Concurrent'],
568 |             target_audience: ['developers']
569 |           });
570 |         });
571 |       }
572 | 
573 |       // Execute all updates
574 |       expect(() => {
575 |         updates.forEach(update => update());
576 |       }).not.toThrow();
577 |     });
578 |   });
579 | });
```

--------------------------------------------------------------------------------
/tests/unit/services/workflow-validator-edge-cases.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 | import { WorkflowValidator } from '@/services/workflow-validator';
  3 | import { NodeRepository } from '@/database/node-repository';
  4 | import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
  5 | import type { WorkflowValidationResult } from '@/services/workflow-validator';
  6 | 
  7 | // NOTE: Mocking EnhancedConfigValidator is challenging because:
  8 | // 1. WorkflowValidator expects the class itself, not an instance
  9 | // 2. The class has static methods that are called directly
 10 | // 3. vi.mock() hoisting makes it difficult to mock properly
 11 | //
 12 | // For properly mocked tests, see workflow-validator-with-mocks.test.ts
 13 | // These tests use a partially mocked approach that may still access the database
 14 | 
 15 | // Mock dependencies
 16 | vi.mock('@/database/node-repository');
 17 | vi.mock('@/services/expression-validator');
 18 | vi.mock('@/utils/logger');
 19 | 
 20 | // Mock EnhancedConfigValidator with static methods
 21 | vi.mock('@/services/enhanced-config-validator', () => ({
 22 |   EnhancedConfigValidator: {
 23 |     validate: vi.fn().mockReturnValue({
 24 |       valid: true,
 25 |       errors: [],
 26 |       warnings: [],
 27 |       suggestions: [],
 28 |       visibleProperties: [],
 29 |       hiddenProperties: []
 30 |     }),
 31 |     validateWithMode: vi.fn().mockReturnValue({
 32 |       valid: true,
 33 |       errors: [],
 34 |       warnings: [],
 35 |       fixedConfig: null
 36 |     })
 37 |   }
 38 | }));
 39 | 
 40 | describe('WorkflowValidator - Edge Cases', () => {
 41 |   let validator: WorkflowValidator;
 42 |   let mockNodeRepository: any;
 43 |   let mockEnhancedConfigValidator: any;
 44 | 
 45 |   beforeEach(() => {
 46 |     vi.clearAllMocks();
 47 |     
 48 |     // Create mock repository that returns node info for test nodes and common n8n nodes
 49 |     mockNodeRepository = {
 50 |       getNode: vi.fn().mockImplementation((type: string) => {
 51 |         if (type === 'test.node' || type === 'test.agent' || type === 'test.tool') {
 52 |           return {
 53 |             name: 'Test Node',
 54 |             type: type,
 55 |             typeVersion: 1,
 56 |             properties: [],
 57 |             package: 'test-package',
 58 |             version: 1,
 59 |             displayName: 'Test Node',
 60 |             isVersioned: false
 61 |           };
 62 |         }
 63 |         // Handle common n8n node types
 64 |         if (type.startsWith('n8n-nodes-base.') || type.startsWith('nodes-base.')) {
 65 |           const nodeName = type.split('.')[1];
 66 |           return {
 67 |             name: nodeName,
 68 |             type: type,
 69 |             typeVersion: 1,
 70 |             properties: [],
 71 |             package: 'n8n-nodes-base',
 72 |             version: 1,
 73 |             displayName: nodeName.charAt(0).toUpperCase() + nodeName.slice(1),
 74 |             isVersioned: ['set', 'httpRequest'].includes(nodeName)
 75 |           };
 76 |         }
 77 |         return null;
 78 |       }),
 79 |       findByType: vi.fn().mockReturnValue({
 80 |         name: 'Test Node',
 81 |         type: 'test.node',
 82 |         typeVersion: 1,
 83 |         properties: []
 84 |       }),
 85 |       searchNodes: vi.fn().mockReturnValue([])
 86 |     };
 87 |     
 88 |     // Ensure EnhancedConfigValidator.validate always returns a valid result
 89 |     vi.mocked(EnhancedConfigValidator.validate).mockReturnValue({
 90 |       valid: true,
 91 |       errors: [],
 92 |       warnings: [],
 93 |       suggestions: [],
 94 |       visibleProperties: [],
 95 |       hiddenProperties: []
 96 |     });
 97 |     
 98 |     // Create validator instance with mocked dependencies
 99 |     validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
100 |   });
101 | 
102 |   describe('Null and Undefined Handling', () => {
103 |     it('should handle null workflow gracefully', async () => {
104 |       const result = await validator.validateWorkflow(null as any);
105 |       expect(result.valid).toBe(false);
106 |       expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true);
107 |     });
108 | 
109 |     it('should handle undefined workflow gracefully', async () => {
110 |       const result = await validator.validateWorkflow(undefined as any);
111 |       expect(result.valid).toBe(false);
112 |       expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true);
113 |     });
114 | 
115 |     it('should handle workflow with null nodes array', async () => {
116 |       const workflow = {
117 |         nodes: null,
118 |         connections: {}
119 |       };
120 |       const result = await validator.validateWorkflow(workflow as any);
121 |       expect(result.valid).toBe(false);
122 |       expect(result.errors.some(e => e.message.includes('nodes must be an array'))).toBe(true);
123 |     });
124 | 
125 |     it('should handle workflow with null connections', async () => {
126 |       const workflow = {
127 |         nodes: [],
128 |         connections: null
129 |       };
130 |       const result = await validator.validateWorkflow(workflow as any);
131 |       expect(result.valid).toBe(false);
132 |       expect(result.errors.some(e => e.message.includes('connections must be an object'))).toBe(true);
133 |     });
134 | 
135 |     it('should handle nodes with null/undefined properties', async () => {
136 |       const workflow = {
137 |         nodes: [
138 |           {
139 |             id: '1',
140 |             name: null,
141 |             type: 'test.node',
142 |             position: [0, 0],
143 |             parameters: undefined
144 |           }
145 |         ],
146 |         connections: {}
147 |       };
148 |       const result = await validator.validateWorkflow(workflow as any);
149 |       expect(result.valid).toBe(false);
150 |       expect(result.errors.length).toBeGreaterThan(0);
151 |     });
152 |   });
153 | 
154 |   describe('Boundary Value Testing', () => {
155 |     it('should handle empty workflow', async () => {
156 |       const workflow = {
157 |         nodes: [],
158 |         connections: {}
159 |       };
160 |       const result = await validator.validateWorkflow(workflow as any);
161 |       expect(result.valid).toBe(true);
162 |       expect(result.warnings.some(w => w.message.includes('empty'))).toBe(true);
163 |     });
164 | 
165 |     it('should handle very large workflows', async () => {
166 |       const nodes = Array(1000).fill(null).map((_, i) => ({
167 |         id: `node${i}`,
168 |         name: `Node ${i}`,
169 |         type: 'test.node',
170 |         position: [i * 100, 0] as [number, number],
171 |         parameters: {}
172 |       }));
173 |       
174 |       const connections: any = {};
175 |       for (let i = 0; i < 999; i++) {
176 |         connections[`Node ${i}`] = {
177 |           main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]]
178 |         };
179 |       }
180 |       
181 |       const workflow = { nodes, connections };
182 |       
183 |       const start = Date.now();
184 |       const result = await validator.validateWorkflow(workflow as any);
185 |       const duration = Date.now() - start;
186 |       
187 |       expect(result).toBeDefined();
188 |       // Use longer timeout for CI environments
189 |       const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
190 |       const timeout = isCI ? 10000 : 5000; // 10 seconds for CI, 5 seconds for local
191 |       expect(duration).toBeLessThan(timeout);
192 |     });
193 | 
194 |     it('should handle deeply nested connections', async () => {
195 |       const workflow = {
196 |         nodes: [
197 |           { id: '1', name: 'Start', type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
198 |           { id: '2', name: 'Middle', type: 'test.node', position: [100, 0] as [number, number], parameters: {} },
199 |           { id: '3', name: 'End', type: 'test.node', position: [200, 0] as [number, number], parameters: {} }
200 |         ],
201 |         connections: {
202 |           'Start': {
203 |             main: [[{ node: 'Middle', type: 'main', index: 0 }]],
204 |             error: [[{ node: 'End', type: 'main', index: 0 }]],
205 |             ai_tool: [[{ node: 'Middle', type: 'ai_tool', index: 0 }]]
206 |           }
207 |         }
208 |       };
209 |       
210 |       const result = await validator.validateWorkflow(workflow as any);
211 |       expect(result.statistics.invalidConnections).toBe(0);
212 |     });
213 | 
214 |     it.skip('should handle nodes at extreme positions - FIXME: mock issues', async () => {
215 |       const workflow = {
216 |         nodes: [
217 |           { id: '1', name: 'FarLeft', type: 'n8n-nodes-base.set', position: [-999999, -999999] as [number, number], parameters: {} },
218 |           { id: '2', name: 'FarRight', type: 'n8n-nodes-base.set', position: [999999, 999999] as [number, number], parameters: {} },
219 |           { id: '3', name: 'Zero', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} }
220 |         ],
221 |         connections: {
222 |           'FarLeft': {
223 |             main: [[{ node: 'FarRight', type: 'main', index: 0 }]]
224 |           },
225 |           'FarRight': {
226 |             main: [[{ node: 'Zero', type: 'main', index: 0 }]]
227 |           }
228 |         }
229 |       };
230 |       
231 |       const result = await validator.validateWorkflow(workflow as any);
232 |       expect(result.valid).toBe(true);
233 |     });
234 |   });
235 | 
236 |   describe('Invalid Data Type Handling', () => {
237 |     it('should handle non-array nodes', async () => {
238 |       const workflow = {
239 |         nodes: 'not-an-array',
240 |         connections: {}
241 |       };
242 |       const result = await validator.validateWorkflow(workflow as any);
243 |       expect(result.valid).toBe(false);
244 |       expect(result.errors[0].message).toContain('nodes must be an array');
245 |     });
246 | 
247 |     it('should handle non-object connections', async () => {
248 |       const workflow = {
249 |         nodes: [],
250 |         connections: []
251 |       };
252 |       const result = await validator.validateWorkflow(workflow as any);
253 |       expect(result.valid).toBe(false);
254 |       expect(result.errors[0].message).toContain('connections must be an object');
255 |     });
256 | 
257 |     it('should handle invalid position values', async () => {
258 |       const workflow = {
259 |         nodes: [
260 |           { id: '1', name: 'InvalidPos', type: 'test.node', position: 'invalid' as any, parameters: {} },
261 |           { id: '2', name: 'NaNPos', type: 'test.node', position: [NaN, NaN] as [number, number], parameters: {} },
262 |           { id: '3', name: 'InfinityPos', type: 'test.node', position: [Infinity, -Infinity] as [number, number], parameters: {} }
263 |         ],
264 |         connections: {}
265 |       };
266 |       
267 |       const result = await validator.validateWorkflow(workflow as any);
268 |       expect(result.errors.length).toBeGreaterThan(0);
269 |     });
270 | 
271 |     it('should handle circular references in workflow object', async () => {
272 |       const workflow: any = {
273 |         nodes: [],
274 |         connections: {}
275 |       };
276 |       workflow.circular = workflow;
277 |       
278 |       await expect(validator.validateWorkflow(workflow)).resolves.toBeDefined();
279 |     });
280 |   });
281 | 
282 |   describe('Connection Validation Edge Cases', () => {
283 |     it('should detect self-referencing nodes', async () => {
284 |       const workflow = {
285 |         nodes: [
286 |           { id: '1', name: 'SelfLoop', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }
287 |         ],
288 |         connections: {
289 |           'SelfLoop': {
290 |             main: [[{ node: 'SelfLoop', type: 'main', index: 0 }]]
291 |           }
292 |         }
293 |       };
294 |       
295 |       const result = await validator.validateWorkflow(workflow as any);
296 |       expect(result.warnings.some(w => w.message.includes('self-referencing'))).toBe(true);
297 |     });
298 | 
299 |     it('should handle non-existent node references', async () => {
300 |       const workflow = {
301 |         nodes: [
302 |           { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }
303 |         ],
304 |         connections: {
305 |           'Node1': {
306 |             main: [[{ node: 'NonExistent', type: 'main', index: 0 }]]
307 |           }
308 |         }
309 |       };
310 |       
311 |       const result = await validator.validateWorkflow(workflow as any);
312 |       expect(result.errors.some(e => e.message.includes('non-existent'))).toBe(true);
313 |     });
314 | 
315 |     it('should handle invalid connection formats', async () => {
316 |       const workflow = {
317 |         nodes: [
318 |           { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }
319 |         ],
320 |         connections: {
321 |           'Node1': {
322 |             main: 'invalid-format' as any
323 |           }
324 |         }
325 |       };
326 |       
327 |       const result = await validator.validateWorkflow(workflow as any);
328 |       expect(result.errors.length).toBeGreaterThan(0);
329 |     });
330 | 
331 |     it('should handle missing connection properties', async () => {
332 |       const workflow = {
333 |         nodes: [
334 |           { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
335 |           { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }
336 |         ],
337 |         connections: {
338 |           'Node1': {
339 |             main: [[{ node: 'Node2' }]] // Missing type and index
340 |           }
341 |         } as any
342 |       };
343 |       
344 |       const result = await validator.validateWorkflow(workflow as any);
345 |       // Should still work as type and index can have defaults
346 |       expect(result.statistics.validConnections).toBeGreaterThan(0);
347 |     });
348 | 
349 |     it('should handle negative output indices', async () => {
350 |       const workflow = {
351 |         nodes: [
352 |           { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
353 |           { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }
354 |         ],
355 |         connections: {
356 |           'Node1': {
357 |             main: [[{ node: 'Node2', type: 'main', index: -1 }]]
358 |           }
359 |         }
360 |       };
361 |       
362 |       const result = await validator.validateWorkflow(workflow as any);
363 |       expect(result.errors.some(e => e.message.includes('Invalid'))).toBe(true);
364 |     });
365 |   });
366 | 
367 |   describe('Special Characters and Unicode', () => {
368 |     // Note: These tests are skipped because WorkflowValidator also needs special character
369 |     // normalization (similar to WorkflowDiffEngine fix in #270). Will be addressed in a future PR.
370 |     it.skip('should handle apostrophes in node names - TODO: needs WorkflowValidator normalization', async () => {
371 |       // Test default n8n Manual Trigger node name with apostrophes
372 |       const workflow = {
373 |         nodes: [
374 |           { id: '1', name: "When clicking 'Execute workflow'", type: 'n8n-nodes-base.manualTrigger', position: [0, 0] as [number, number], parameters: {} },
375 |           { id: '2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 0] as [number, number], parameters: {} }
376 |         ],
377 |         connections: {
378 |           "When clicking 'Execute workflow'": {
379 |             main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
380 |           }
381 |         }
382 |       };
383 | 
384 |       const result = await validator.validateWorkflow(workflow as any);
385 |       expect(result.valid).toBe(true);
386 |       expect(result.errors).toHaveLength(0);
387 |     });
388 | 
389 |     it.skip('should handle special characters in node names - TODO: needs WorkflowValidator normalization', async () => {
390 |       const workflow = {
391 |         nodes: [
392 |           { id: '1', name: 'Node@#$%', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} },
393 |           { id: '2', name: 'Node 中文', type: 'n8n-nodes-base.set', position: [100, 0] as [number, number], parameters: {} },
394 |           { id: '3', name: 'Node😊', type: 'n8n-nodes-base.set', position: [200, 0] as [number, number], parameters: {} }
395 |         ],
396 |         connections: {
397 |           'Node@#$%': {
398 |             main: [[{ node: 'Node 中文', type: 'main', index: 0 }]]
399 |           },
400 |           'Node 中文': {
401 |             main: [[{ node: 'Node😊', type: 'main', index: 0 }]]
402 |           }
403 |         }
404 |       };
405 | 
406 |       const result = await validator.validateWorkflow(workflow as any);
407 |       expect(result.valid).toBe(true);
408 |       expect(result.errors).toHaveLength(0);
409 |     });
410 | 
411 |     it('should handle very long node names', async () => {
412 |       const longName = 'A'.repeat(1000);
413 |       const workflow = {
414 |         nodes: [
415 |           { id: '1', name: longName, type: 'test.node', position: [0, 0] as [number, number], parameters: {} }
416 |         ],
417 |         connections: {}
418 |       };
419 |       
420 |       const result = await validator.validateWorkflow(workflow as any);
421 |       expect(result.warnings.some(w => w.message.includes('very long'))).toBe(true);
422 |     });
423 |   });
424 | 
425 |   describe('Batch Validation', () => {
426 |     it.skip('should handle batch validation with mixed valid/invalid workflows - FIXME: mock issues', async () => {
427 |       const workflows = [
428 |         {
429 |           nodes: [
430 |             { id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} },
431 |             { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [100, 0] as [number, number], parameters: {} }
432 |           ],
433 |           connections: {
434 |             'Node1': {
435 |               main: [[{ node: 'Node2', type: 'main', index: 0 }]]
436 |             }
437 |           }
438 |         },
439 |         null as any,
440 |         {
441 |           nodes: 'invalid' as any,
442 |           connections: {}
443 |         }
444 |       ];
445 |       
446 |       const promises = workflows.map(w => validator.validateWorkflow(w));
447 |       const results = await Promise.all(promises);
448 |       
449 |       expect(results[0].valid).toBe(true);
450 |       expect(results[1].valid).toBe(false);
451 |       expect(results[2].valid).toBe(false);
452 |     });
453 | 
454 |     it.skip('should handle concurrent validation requests - FIXME: mock issues', async () => {
455 |       const workflow = {
456 |         nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: {} }],
457 |         connections: {}
458 |       };
459 |       
460 |       const promises = Array(10).fill(null).map(() => validator.validateWorkflow(workflow));
461 |       const results = await Promise.all(promises);
462 |       
463 |       expect(results.every(r => r.valid)).toBe(true);
464 |     });
465 |   });
466 | 
467 |   describe('Expression Validation Edge Cases', () => {
468 |     it('should skip expression validation when option is false', async () => {
469 |       const workflow = {
470 |         nodes: [{
471 |           id: '1',
472 |           name: 'Node1',
473 |           type: 'test.node',
474 |           position: [0, 0] as [number, number],
475 |           parameters: {
476 |             value: '{{ $json.invalid.expression }}'
477 |           }
478 |         }],
479 |         connections: {}
480 |       };
481 |       
482 |       const result = await validator.validateWorkflow(workflow, {
483 |         validateExpressions: false
484 |       });
485 |       
486 |       expect(result.statistics.expressionsValidated).toBe(0);
487 |     });
488 |   });
489 | 
490 |   describe('Connection Type Validation', () => {
491 |     it('should validate different connection types', async () => {
492 |       const workflow = {
493 |         nodes: [
494 |           { id: '1', name: 'Agent', type: 'test.agent', position: [0, 0] as [number, number], parameters: {} },
495 |           { id: '2', name: 'Tool', type: 'test.tool', position: [100, 0] as [number, number], parameters: {} }
496 |         ],
497 |         connections: {
498 |           'Tool': {
499 |             ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]]
500 |           }
501 |         }
502 |       };
503 |       
504 |       const result = await validator.validateWorkflow(workflow as any);
505 |       expect(result.statistics.validConnections).toBeGreaterThan(0);
506 |     });
507 |   });
508 | 
509 |   describe('Error Recovery', () => {
510 |     it('should continue validation after encountering errors', async () => {
511 |       const workflow = {
512 |         nodes: [
513 |           { id: '1', name: null as any, type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
514 |           { id: '2', name: 'Valid', type: 'test.node', position: [100, 0] as [number, number], parameters: {} },
515 |           { id: '3', name: 'AlsoValid', type: 'test.node', position: [200, 0] as [number, number], parameters: {} }
516 |         ],
517 |         connections: {
518 |           'Valid': {
519 |             main: [[{ node: 'AlsoValid', type: 'main', index: 0 }]]
520 |           }
521 |         }
522 |       };
523 |       
524 |       const result = await validator.validateWorkflow(workflow as any);
525 |       expect(result.errors.length).toBeGreaterThan(0);
526 |       expect(result.statistics.validConnections).toBeGreaterThan(0);
527 |     });
528 |   });
529 | 
530 |   describe('Static Method Alternatives', () => {
531 |     it('should validate workflow connections only', async () => {
532 |       const workflow = {
533 |         nodes: [
534 |           { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
535 |           { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }
536 |         ],
537 |         connections: {
538 |           'Node1': {
539 |             main: [[{ node: 'Node2', type: 'main', index: 0 }]]
540 |           }
541 |         }
542 |       };
543 |       
544 |       const result = await validator.validateWorkflow(workflow, {
545 |         validateNodes: false,
546 |         validateExpressions: false,
547 |         validateConnections: true
548 |       });
549 |       
550 |       expect(result.statistics.validConnections).toBe(1);
551 |     });
552 | 
553 |     it('should validate workflow expressions only', async () => {
554 |       const workflow = {
555 |         nodes: [{
556 |           id: '1',
557 |           name: 'Node1',
558 |           type: 'test.node',
559 |           position: [0, 0] as [number, number],
560 |           parameters: {
561 |             value: '{{ $json.data }}'
562 |           }
563 |         }],
564 |         connections: {}
565 |       };
566 |       
567 |       const result = await validator.validateWorkflow(workflow, {
568 |         validateNodes: false,
569 |         validateExpressions: true,
570 |         validateConnections: false
571 |       });
572 |       
573 |       expect(result.statistics.expressionsValidated).toBeGreaterThan(0);
574 |     });
575 |   });
576 | });
```

--------------------------------------------------------------------------------
/src/services/ai-node-validator.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * AI Node Validator
  3 |  *
  4 |  * Implements validation logic for AI Agent, Chat Trigger, and Basic LLM Chain nodes
  5 |  * from docs/FINAL_AI_VALIDATION_SPEC.md
  6 |  *
  7 |  * Key Features:
  8 |  * - Reverse connection mapping (AI connections flow TO the consumer)
  9 |  * - AI Agent comprehensive validation (prompt types, fallback models, streaming mode)
 10 |  * - Chat Trigger validation (streaming mode constraints)
 11 |  * - Integration with AI tool validators
 12 |  */
 13 | 
 14 | import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
 15 | import {
 16 |   WorkflowNode,
 17 |   WorkflowJson,
 18 |   ReverseConnection,
 19 |   ValidationIssue,
 20 |   isAIToolSubNode,
 21 |   validateAIToolSubNode
 22 | } from './ai-tool-validators';
 23 | 
 24 | // Re-export types for test files
 25 | export type {
 26 |   WorkflowNode,
 27 |   WorkflowJson,
 28 |   ReverseConnection,
 29 |   ValidationIssue
 30 | } from './ai-tool-validators';
 31 | 
 32 | // Validation constants
 33 | const MIN_SYSTEM_MESSAGE_LENGTH = 20;
 34 | const MAX_ITERATIONS_WARNING_THRESHOLD = 50;
 35 | 
 36 | /**
 37 |  * AI Connection Types
 38 |  * From spec lines 551-596
 39 |  */
 40 | export const AI_CONNECTION_TYPES = [
 41 |   'ai_languageModel',
 42 |   'ai_memory',
 43 |   'ai_tool',
 44 |   'ai_embedding',
 45 |   'ai_vectorStore',
 46 |   'ai_document',
 47 |   'ai_textSplitter',
 48 |   'ai_outputParser'
 49 | ] as const;
 50 | 
 51 | /**
 52 |  * Build Reverse Connection Map
 53 |  *
 54 |  * CRITICAL: AI connections flow TO the consumer node (reversed from standard n8n pattern)
 55 |  * This utility maps which nodes connect TO each node, essential for AI validation.
 56 |  *
 57 |  * From spec lines 551-596
 58 |  *
 59 |  * @example
 60 |  * Standard n8n: [Source] --main--> [Target]
 61 |  * workflow.connections["Source"]["main"] = [[{node: "Target", ...}]]
 62 |  *
 63 |  * AI pattern: [Language Model] --ai_languageModel--> [AI Agent]
 64 |  * workflow.connections["Language Model"]["ai_languageModel"] = [[{node: "AI Agent", ...}]]
 65 |  *
 66 |  * Reverse map: reverseMap.get("AI Agent") = [{sourceName: "Language Model", type: "ai_languageModel", ...}]
 67 |  */
 68 | export function buildReverseConnectionMap(
 69 |   workflow: WorkflowJson
 70 | ): Map<string, ReverseConnection[]> {
 71 |   const map = new Map<string, ReverseConnection[]>();
 72 | 
 73 |   // Iterate through all connections
 74 |   for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
 75 |     // Validate source name is not empty
 76 |     if (!sourceName || typeof sourceName !== 'string' || sourceName.trim() === '') {
 77 |       continue;
 78 |     }
 79 | 
 80 |     if (!outputs || typeof outputs !== 'object') continue;
 81 | 
 82 |     // Iterate through all output types (main, error, ai_tool, ai_languageModel, etc.)
 83 |     for (const [outputType, connections] of Object.entries(outputs)) {
 84 |       if (!Array.isArray(connections)) continue;
 85 | 
 86 |       // Flatten nested arrays and process each connection
 87 |       const connArray = connections.flat().filter(c => c);
 88 | 
 89 |       for (const conn of connArray) {
 90 |         if (!conn || !conn.node) continue;
 91 | 
 92 |         // Validate target node name is not empty
 93 |         if (typeof conn.node !== 'string' || conn.node.trim() === '') {
 94 |           continue;
 95 |         }
 96 | 
 97 |         // Initialize array for target node if not exists
 98 |         if (!map.has(conn.node)) {
 99 |           map.set(conn.node, []);
100 |         }
101 | 
102 |         // Add reverse connection entry
103 |         map.get(conn.node)!.push({
104 |           sourceName: sourceName,
105 |           sourceType: outputType,
106 |           type: outputType,
107 |           index: conn.index ?? 0
108 |         });
109 |       }
110 |     }
111 |   }
112 | 
113 |   return map;
114 | }
115 | 
116 | /**
117 |  * Get AI connections TO a specific node
118 |  */
119 | export function getAIConnections(
120 |   nodeName: string,
121 |   reverseConnections: Map<string, ReverseConnection[]>,
122 |   connectionType?: string
123 | ): ReverseConnection[] {
124 |   const incoming = reverseConnections.get(nodeName) || [];
125 | 
126 |   if (connectionType) {
127 |     return incoming.filter(c => c.type === connectionType);
128 |   }
129 | 
130 |   return incoming.filter(c => AI_CONNECTION_TYPES.includes(c.type as any));
131 | }
132 | 
133 | /**
134 |  * Validate AI Agent Node
135 |  * From spec lines 3-549
136 |  *
137 |  * Validates:
138 |  * - Language model connections (1 or 2 if fallback)
139 |  * - Output parser connection + hasOutputParser flag
140 |  * - Prompt type configuration (auto vs define)
141 |  * - System message recommendations
142 |  * - Streaming mode constraints (CRITICAL)
143 |  * - Memory connections (0-1)
144 |  * - Tool connections
145 |  * - maxIterations validation
146 |  */
147 | export function validateAIAgent(
148 |   node: WorkflowNode,
149 |   reverseConnections: Map<string, ReverseConnection[]>,
150 |   workflow: WorkflowJson
151 | ): ValidationIssue[] {
152 |   const issues: ValidationIssue[] = [];
153 |   const incoming = reverseConnections.get(node.name) || [];
154 | 
155 |   // 1. Validate language model connections (REQUIRED: 1 or 2 if fallback)
156 |   const languageModelConnections = incoming.filter(c => c.type === 'ai_languageModel');
157 | 
158 |   if (languageModelConnections.length === 0) {
159 |     issues.push({
160 |       severity: 'error',
161 |       nodeId: node.id,
162 |       nodeName: node.name,
163 |       message: `AI Agent "${node.name}" requires an ai_languageModel connection. Connect a language model node (e.g., OpenAI Chat Model, Anthropic Chat Model).`,
164 |       code: 'MISSING_LANGUAGE_MODEL'
165 |     });
166 |   } else if (languageModelConnections.length > 2) {
167 |     issues.push({
168 |       severity: 'error',
169 |       nodeId: node.id,
170 |       nodeName: node.name,
171 |       message: `AI Agent "${node.name}" has ${languageModelConnections.length} ai_languageModel connections. Maximum is 2 (for fallback model support).`,
172 |       code: 'TOO_MANY_LANGUAGE_MODELS'
173 |     });
174 |   } else if (languageModelConnections.length === 2) {
175 |     // Check if fallback is enabled
176 |     if (!node.parameters.needsFallback) {
177 |       issues.push({
178 |         severity: 'warning',
179 |         nodeId: node.id,
180 |         nodeName: node.name,
181 |         message: `AI Agent "${node.name}" has 2 language models but needsFallback is not enabled. Set needsFallback=true or remove the second model.`
182 |       });
183 |     }
184 |   } else if (languageModelConnections.length === 1 && node.parameters.needsFallback === true) {
185 |     issues.push({
186 |       severity: 'error',
187 |       nodeId: node.id,
188 |       nodeName: node.name,
189 |       message: `AI Agent "${node.name}" has needsFallback=true but only 1 language model connected. Connect a second model for fallback or disable needsFallback.`,
190 |       code: 'FALLBACK_MISSING_SECOND_MODEL'
191 |     });
192 |   }
193 | 
194 |   // 2. Validate output parser configuration
195 |   const outputParserConnections = incoming.filter(c => c.type === 'ai_outputParser');
196 | 
197 |   if (node.parameters.hasOutputParser === true) {
198 |     if (outputParserConnections.length === 0) {
199 |       issues.push({
200 |         severity: 'error',
201 |         nodeId: node.id,
202 |         nodeName: node.name,
203 |         message: `AI Agent "${node.name}" has hasOutputParser=true but no ai_outputParser connection. Connect an output parser or set hasOutputParser=false.`,
204 |         code: 'MISSING_OUTPUT_PARSER'
205 |       });
206 |     }
207 |   } else if (outputParserConnections.length > 0) {
208 |     issues.push({
209 |       severity: 'warning',
210 |       nodeId: node.id,
211 |       nodeName: node.name,
212 |       message: `AI Agent "${node.name}" has an output parser connected but hasOutputParser is not true. Set hasOutputParser=true to enable output parsing.`
213 |     });
214 |   }
215 | 
216 |   if (outputParserConnections.length > 1) {
217 |     issues.push({
218 |       severity: 'error',
219 |       nodeId: node.id,
220 |       nodeName: node.name,
221 |       message: `AI Agent "${node.name}" has ${outputParserConnections.length} output parsers. Only 1 is allowed.`,
222 |       code: 'MULTIPLE_OUTPUT_PARSERS'
223 |     });
224 |   }
225 | 
226 |   // 3. Validate prompt type configuration
227 |   if (node.parameters.promptType === 'define') {
228 |     if (!node.parameters.text || node.parameters.text.trim() === '') {
229 |       issues.push({
230 |         severity: 'error',
231 |         nodeId: node.id,
232 |         nodeName: node.name,
233 |         message: `AI Agent "${node.name}" has promptType="define" but the text field is empty. Provide a custom prompt or switch to promptType="auto".`,
234 |         code: 'MISSING_PROMPT_TEXT'
235 |       });
236 |     }
237 |   }
238 | 
239 |   // 4. Check system message (RECOMMENDED)
240 |   if (!node.parameters.systemMessage) {
241 |     issues.push({
242 |       severity: 'info',
243 |       nodeId: node.id,
244 |       nodeName: node.name,
245 |       message: `AI Agent "${node.name}" has no systemMessage. Consider adding one to define the agent's role, capabilities, and constraints.`
246 |     });
247 |   } else if (node.parameters.systemMessage.trim().length < MIN_SYSTEM_MESSAGE_LENGTH) {
248 |     issues.push({
249 |       severity: 'info',
250 |       nodeId: node.id,
251 |       nodeName: node.name,
252 |       message: `AI Agent "${node.name}" systemMessage is very short (minimum ${MIN_SYSTEM_MESSAGE_LENGTH} characters recommended). Provide more detail about the agent's role and capabilities.`
253 |     });
254 |   }
255 | 
256 |   // 5. Validate streaming mode constraints (CRITICAL)
257 |   // From spec lines 753-879: AI Agent with streaming MUST NOT have main output connections
258 |   const isStreamingTarget = checkIfStreamingTarget(node, workflow, reverseConnections);
259 |   const hasOwnStreamingEnabled = node.parameters?.options?.streamResponse === true;
260 | 
261 |   if (isStreamingTarget || hasOwnStreamingEnabled) {
262 |     // Check if AI Agent has any main output connections
263 |     const agentMainOutput = workflow.connections[node.name]?.main;
264 |     if (agentMainOutput && agentMainOutput.flat().some((c: any) => c)) {
265 |       const streamSource = isStreamingTarget
266 |         ? 'connected from Chat Trigger with responseMode="streaming"'
267 |         : 'has streamResponse=true in options';
268 |       issues.push({
269 |         severity: 'error',
270 |         nodeId: node.id,
271 |         nodeName: node.name,
272 |         message: `AI Agent "${node.name}" is in streaming mode (${streamSource}) but has outgoing main connections. Remove all main output connections - streaming responses flow back through the Chat Trigger.`,
273 |         code: 'STREAMING_WITH_MAIN_OUTPUT'
274 |       });
275 |     }
276 |   }
277 | 
278 |   // 6. Validate memory connections (0-1 allowed)
279 |   const memoryConnections = incoming.filter(c => c.type === 'ai_memory');
280 | 
281 |   if (memoryConnections.length > 1) {
282 |     issues.push({
283 |       severity: 'error',
284 |       nodeId: node.id,
285 |       nodeName: node.name,
286 |       message: `AI Agent "${node.name}" has ${memoryConnections.length} ai_memory connections. Only 1 memory is allowed.`,
287 |       code: 'MULTIPLE_MEMORY_CONNECTIONS'
288 |     });
289 |   }
290 | 
291 |   // 7. Validate tool connections
292 |   const toolConnections = incoming.filter(c => c.type === 'ai_tool');
293 | 
294 |   if (toolConnections.length === 0) {
295 |     issues.push({
296 |       severity: 'info',
297 |       nodeId: node.id,
298 |       nodeName: node.name,
299 |       message: `AI Agent "${node.name}" has no ai_tool connections. Consider adding tools to enhance the agent's capabilities.`
300 |     });
301 |   }
302 | 
303 |   // 8. Validate maxIterations if specified
304 |   if (node.parameters.maxIterations !== undefined) {
305 |     if (typeof node.parameters.maxIterations !== 'number') {
306 |       issues.push({
307 |         severity: 'error',
308 |         nodeId: node.id,
309 |         nodeName: node.name,
310 |         message: `AI Agent "${node.name}" has invalid maxIterations type. Must be a number.`,
311 |         code: 'INVALID_MAX_ITERATIONS_TYPE'
312 |       });
313 |     } else if (node.parameters.maxIterations < 1) {
314 |       issues.push({
315 |         severity: 'error',
316 |         nodeId: node.id,
317 |         nodeName: node.name,
318 |         message: `AI Agent "${node.name}" has maxIterations=${node.parameters.maxIterations}. Must be at least 1.`,
319 |         code: 'MAX_ITERATIONS_TOO_LOW'
320 |       });
321 |     } else if (node.parameters.maxIterations > MAX_ITERATIONS_WARNING_THRESHOLD) {
322 |       issues.push({
323 |         severity: 'warning',
324 |         nodeId: node.id,
325 |         nodeName: node.name,
326 |         message: `AI Agent "${node.name}" has maxIterations=${node.parameters.maxIterations}. Very high iteration counts (>${MAX_ITERATIONS_WARNING_THRESHOLD}) may cause long execution times and high costs.`
327 |       });
328 |     }
329 |   }
330 | 
331 |   return issues;
332 | }
333 | 
334 | /**
335 |  * Check if AI Agent is a streaming target
336 |  * Helper function to determine if an AI Agent is receiving streaming input from Chat Trigger
337 |  */
338 | function checkIfStreamingTarget(
339 |   node: WorkflowNode,
340 |   workflow: WorkflowJson,
341 |   reverseConnections: Map<string, ReverseConnection[]>
342 | ): boolean {
343 |   const incoming = reverseConnections.get(node.name) || [];
344 | 
345 |   // Check if any incoming main connection is from a Chat Trigger with streaming enabled
346 |   const mainConnections = incoming.filter(c => c.type === 'main');
347 | 
348 |   for (const conn of mainConnections) {
349 |     const sourceNode = workflow.nodes.find(n => n.name === conn.sourceName);
350 |     if (!sourceNode) continue;
351 | 
352 |     const normalizedType = NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
353 |     if (normalizedType === 'nodes-langchain.chatTrigger') {
354 |       const responseMode = sourceNode.parameters?.options?.responseMode || 'lastNode';
355 |       if (responseMode === 'streaming') {
356 |         return true;
357 |       }
358 |     }
359 |   }
360 | 
361 |   return false;
362 | }
363 | 
364 | /**
365 |  * Validate Chat Trigger Node
366 |  * From spec lines 753-879
367 |  *
368 |  * Critical validations:
369 |  * - responseMode="streaming" requires AI Agent target
370 |  * - AI Agent with streaming MUST NOT have main output connections
371 |  * - responseMode="lastNode" validation
372 |  */
373 | export function validateChatTrigger(
374 |   node: WorkflowNode,
375 |   workflow: WorkflowJson,
376 |   reverseConnections: Map<string, ReverseConnection[]>
377 | ): ValidationIssue[] {
378 |   const issues: ValidationIssue[] = [];
379 | 
380 |   const responseMode = node.parameters?.options?.responseMode || 'lastNode';
381 | 
382 |   // Get outgoing main connections from Chat Trigger
383 |   const outgoingMain = workflow.connections[node.name]?.main;
384 |   if (!outgoingMain || outgoingMain.length === 0 || !outgoingMain[0] || outgoingMain[0].length === 0) {
385 |     issues.push({
386 |       severity: 'error',
387 |       nodeId: node.id,
388 |       nodeName: node.name,
389 |       message: `Chat Trigger "${node.name}" has no outgoing connections. Connect it to an AI Agent or workflow.`,
390 |       code: 'MISSING_CONNECTIONS'
391 |     });
392 |     return issues;
393 |   }
394 | 
395 |   const firstConnection = outgoingMain[0][0];
396 |   if (!firstConnection) {
397 |     return issues;
398 |   }
399 | 
400 |   const targetNode = workflow.nodes.find(n => n.name === firstConnection.node);
401 |   if (!targetNode) {
402 |     issues.push({
403 |       severity: 'error',
404 |       nodeId: node.id,
405 |       nodeName: node.name,
406 |       message: `Chat Trigger "${node.name}" connects to non-existent node "${firstConnection.node}".`,
407 |       code: 'INVALID_TARGET_NODE'
408 |     });
409 |     return issues;
410 |   }
411 | 
412 |   const targetType = NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
413 | 
414 |   // Validate streaming mode
415 |   if (responseMode === 'streaming') {
416 |     // CRITICAL: Streaming mode only works with AI Agent
417 |     if (targetType !== 'nodes-langchain.agent') {
418 |       issues.push({
419 |         severity: 'error',
420 |         nodeId: node.id,
421 |         nodeName: node.name,
422 |         message: `Chat Trigger "${node.name}" has responseMode="streaming" but connects to "${targetNode.name}" (${targetType}). Streaming mode only works with AI Agent. Change responseMode to "lastNode" or connect to an AI Agent.`,
423 |         code: 'STREAMING_WRONG_TARGET'
424 |       });
425 |     } else {
426 |       // CRITICAL: Check AI Agent has NO main output connections
427 |       const agentMainOutput = workflow.connections[targetNode.name]?.main;
428 |       if (agentMainOutput && agentMainOutput.flat().some((c: any) => c)) {
429 |         issues.push({
430 |           severity: 'error',
431 |           nodeId: targetNode.id,
432 |           nodeName: targetNode.name,
433 |           message: `AI Agent "${targetNode.name}" is in streaming mode but has outgoing main connections. In streaming mode, the AI Agent must NOT have main output connections - responses stream back through the Chat Trigger.`,
434 |           code: 'STREAMING_AGENT_HAS_OUTPUT'
435 |         });
436 |       }
437 |     }
438 |   }
439 | 
440 |   // Validate lastNode mode
441 |   if (responseMode === 'lastNode') {
442 |     // lastNode mode requires a workflow that ends somewhere
443 |     // Just informational - this is the default and works with any workflow
444 |     if (targetType === 'nodes-langchain.agent') {
445 |       issues.push({
446 |         severity: 'info',
447 |         nodeId: node.id,
448 |         nodeName: node.name,
449 |         message: `Chat Trigger "${node.name}" uses responseMode="lastNode" with AI Agent. Consider using responseMode="streaming" for better user experience with real-time responses.`
450 |       });
451 |     }
452 |   }
453 | 
454 |   return issues;
455 | }
456 | 
457 | /**
458 |  * Validate Basic LLM Chain Node
459 |  * From spec - simplified AI chain without agent loop
460 |  *
461 |  * Similar to AI Agent but simpler:
462 |  * - Requires exactly 1 language model
463 |  * - Can have 0-1 memory
464 |  * - No tools (not an agent)
465 |  * - No fallback model support
466 |  */
467 | export function validateBasicLLMChain(
468 |   node: WorkflowNode,
469 |   reverseConnections: Map<string, ReverseConnection[]>
470 | ): ValidationIssue[] {
471 |   const issues: ValidationIssue[] = [];
472 |   const incoming = reverseConnections.get(node.name) || [];
473 | 
474 |   // 1. Validate language model connection (REQUIRED: exactly 1)
475 |   const languageModelConnections = incoming.filter(c => c.type === 'ai_languageModel');
476 | 
477 |   if (languageModelConnections.length === 0) {
478 |     issues.push({
479 |       severity: 'error',
480 |       nodeId: node.id,
481 |       nodeName: node.name,
482 |       message: `Basic LLM Chain "${node.name}" requires an ai_languageModel connection. Connect a language model node.`,
483 |       code: 'MISSING_LANGUAGE_MODEL'
484 |     });
485 |   } else if (languageModelConnections.length > 1) {
486 |     issues.push({
487 |       severity: 'error',
488 |       nodeId: node.id,
489 |       nodeName: node.name,
490 |       message: `Basic LLM Chain "${node.name}" has ${languageModelConnections.length} ai_languageModel connections. Basic LLM Chain only supports 1 language model (no fallback).`,
491 |       code: 'MULTIPLE_LANGUAGE_MODELS'
492 |     });
493 |   }
494 | 
495 |   // 2. Validate memory connections (0-1 allowed)
496 |   const memoryConnections = incoming.filter(c => c.type === 'ai_memory');
497 | 
498 |   if (memoryConnections.length > 1) {
499 |     issues.push({
500 |       severity: 'error',
501 |       nodeId: node.id,
502 |       nodeName: node.name,
503 |       message: `Basic LLM Chain "${node.name}" has ${memoryConnections.length} ai_memory connections. Only 1 memory is allowed.`,
504 |       code: 'MULTIPLE_MEMORY_CONNECTIONS'
505 |     });
506 |   }
507 | 
508 |   // 3. Check for tool connections (not supported)
509 |   const toolConnections = incoming.filter(c => c.type === 'ai_tool');
510 | 
511 |   if (toolConnections.length > 0) {
512 |     issues.push({
513 |       severity: 'error',
514 |       nodeId: node.id,
515 |       nodeName: node.name,
516 |       message: `Basic LLM Chain "${node.name}" has ai_tool connections. Basic LLM Chain does not support tools. Use AI Agent if you need tool support.`,
517 |       code: 'TOOLS_NOT_SUPPORTED'
518 |     });
519 |   }
520 | 
521 |   // 4. Validate prompt configuration
522 |   if (node.parameters.promptType === 'define') {
523 |     if (!node.parameters.text || node.parameters.text.trim() === '') {
524 |       issues.push({
525 |         severity: 'error',
526 |         nodeId: node.id,
527 |         nodeName: node.name,
528 |         message: `Basic LLM Chain "${node.name}" has promptType="define" but the text field is empty.`,
529 |         code: 'MISSING_PROMPT_TEXT'
530 |       });
531 |     }
532 |   }
533 | 
534 |   return issues;
535 | }
536 | 
537 | /**
538 |  * Validate all AI-specific nodes in a workflow
539 |  *
540 |  * This is the main entry point called by WorkflowValidator
541 |  */
542 | export function validateAISpecificNodes(
543 |   workflow: WorkflowJson
544 | ): ValidationIssue[] {
545 |   const issues: ValidationIssue[] = [];
546 | 
547 |   // Build reverse connection map (critical for AI validation)
548 |   const reverseConnectionMap = buildReverseConnectionMap(workflow);
549 | 
550 |   for (const node of workflow.nodes) {
551 |     if (node.disabled) continue;
552 | 
553 |     const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type);
554 | 
555 |     // Validate AI Agent nodes
556 |     if (normalizedType === 'nodes-langchain.agent') {
557 |       const nodeIssues = validateAIAgent(node, reverseConnectionMap, workflow);
558 |       issues.push(...nodeIssues);
559 |     }
560 | 
561 |     // Validate Chat Trigger nodes
562 |     if (normalizedType === 'nodes-langchain.chatTrigger') {
563 |       const nodeIssues = validateChatTrigger(node, workflow, reverseConnectionMap);
564 |       issues.push(...nodeIssues);
565 |     }
566 | 
567 |     // Validate Basic LLM Chain nodes
568 |     if (normalizedType === 'nodes-langchain.chainLlm') {
569 |       const nodeIssues = validateBasicLLMChain(node, reverseConnectionMap);
570 |       issues.push(...nodeIssues);
571 |     }
572 | 
573 |     // Validate AI tool sub-nodes (13 types)
574 |     if (isAIToolSubNode(normalizedType)) {
575 |       const nodeIssues = validateAIToolSubNode(
576 |         node,
577 |         normalizedType,
578 |         reverseConnectionMap,
579 |         workflow
580 |       );
581 |       issues.push(...nodeIssues);
582 |     }
583 |   }
584 | 
585 |   return issues;
586 | }
587 | 
588 | /**
589 |  * Check if a workflow contains any AI nodes
590 |  * Useful for skipping AI validation when not needed
591 |  */
592 | export function hasAINodes(workflow: WorkflowJson): boolean {
593 |   const aiNodeTypes = [
594 |     'nodes-langchain.agent',
595 |     'nodes-langchain.chatTrigger',
596 |     'nodes-langchain.chainLlm',
597 |   ];
598 | 
599 |   return workflow.nodes.some(node => {
600 |     const normalized = NodeTypeNormalizer.normalizeToFullForm(node.type);
601 |     return aiNodeTypes.includes(normalized) || isAIToolSubNode(normalized);
602 |   });
603 | }
604 | 
605 | /**
606 |  * Helper: Get AI node type category
607 |  */
608 | export function getAINodeCategory(nodeType: string): string | null {
609 |   const normalized = NodeTypeNormalizer.normalizeToFullForm(nodeType);
610 | 
611 |   if (normalized === 'nodes-langchain.agent') return 'AI Agent';
612 |   if (normalized === 'nodes-langchain.chatTrigger') return 'Chat Trigger';
613 |   if (normalized === 'nodes-langchain.chainLlm') return 'Basic LLM Chain';
614 |   if (isAIToolSubNode(normalized)) return 'AI Tool';
615 | 
616 |   // Check for AI component nodes
617 |   if (normalized.startsWith('nodes-langchain.')) {
618 |     if (normalized.includes('openAi') || normalized.includes('anthropic') || normalized.includes('googleGemini')) {
619 |       return 'Language Model';
620 |     }
621 |     if (normalized.includes('memory') || normalized.includes('buffer')) {
622 |       return 'Memory';
623 |     }
624 |     if (normalized.includes('vectorStore') || normalized.includes('pinecone') || normalized.includes('qdrant')) {
625 |       return 'Vector Store';
626 |     }
627 |     if (normalized.includes('embedding')) {
628 |       return 'Embeddings';
629 |     }
630 |     return 'AI Component';
631 |   }
632 | 
633 |   return null;
634 | }
635 | 
```

--------------------------------------------------------------------------------
/tests/unit/parsers/simple-parser.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach } from 'vitest';
  2 | import { SimpleParser } from '@/parsers/simple-parser';
  3 | import {
  4 |   programmaticNodeFactory,
  5 |   declarativeNodeFactory,
  6 |   triggerNodeFactory,
  7 |   webhookNodeFactory,
  8 |   aiToolNodeFactory,
  9 |   versionedNodeClassFactory,
 10 |   versionedNodeTypeClassFactory,
 11 |   malformedNodeFactory,
 12 |   nodeClassFactory,
 13 |   propertyFactory,
 14 |   stringPropertyFactory,
 15 |   resourcePropertyFactory,
 16 |   operationPropertyFactory
 17 | } from '@tests/fixtures/factories/parser-node.factory';
 18 | 
 19 | describe('SimpleParser', () => {
 20 |   let parser: SimpleParser;
 21 | 
 22 |   beforeEach(() => {
 23 |     parser = new SimpleParser();
 24 |   });
 25 | 
 26 |   describe('parse method', () => {
 27 |     it('should parse a basic programmatic node', () => {
 28 |       const nodeDefinition = programmaticNodeFactory.build();
 29 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
 30 |       
 31 |       const result = parser.parse(NodeClass as any);
 32 |       
 33 |       expect(result).toMatchObject({
 34 |         style: 'programmatic',
 35 |         nodeType: nodeDefinition.name,
 36 |         displayName: nodeDefinition.displayName,
 37 |         description: nodeDefinition.description,
 38 |         category: nodeDefinition.group?.[0],
 39 |         properties: nodeDefinition.properties,
 40 |         credentials: nodeDefinition.credentials || [],
 41 |         isAITool: false,
 42 |         isWebhook: false,
 43 |         version: nodeDefinition.version?.toString() || '1',
 44 |         isVersioned: false,
 45 |         isTrigger: false,
 46 |         operations: expect.any(Array)
 47 |       });
 48 |     });
 49 | 
 50 |     it('should parse a declarative node', () => {
 51 |       const nodeDefinition = declarativeNodeFactory.build();
 52 |       // Fix the routing structure for simple parser - it expects operation.options to be an array
 53 |       nodeDefinition.routing.request!.operation = {
 54 |         options: [
 55 |           { name: 'Create User', value: 'createUser' },
 56 |           { name: 'Get User', value: 'getUser' }
 57 |         ]
 58 |       } as any;
 59 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
 60 |       
 61 |       const result = parser.parse(NodeClass as any);
 62 |       
 63 |       expect(result.style).toBe('declarative');
 64 |       expect(result.operations.length).toBeGreaterThan(0);
 65 |     });
 66 | 
 67 |     it('should detect trigger nodes', () => {
 68 |       const nodeDefinition = triggerNodeFactory.build();
 69 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
 70 |       
 71 |       const result = parser.parse(NodeClass as any);
 72 |       
 73 |       expect(result.isTrigger).toBe(true);
 74 |     });
 75 | 
 76 |     it('should detect webhook nodes', () => {
 77 |       const nodeDefinition = webhookNodeFactory.build();
 78 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
 79 |       
 80 |       const result = parser.parse(NodeClass as any);
 81 |       
 82 |       expect(result.isWebhook).toBe(true);
 83 |     });
 84 | 
 85 |     it('should detect AI tool nodes', () => {
 86 |       const nodeDefinition = aiToolNodeFactory.build();
 87 |       // Fix the routing structure for simple parser
 88 |       nodeDefinition.routing.request!.operation = {
 89 |         options: [
 90 |           { name: 'Create', value: 'create' }
 91 |         ]
 92 |       } as any;
 93 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
 94 |       
 95 |       const result = parser.parse(NodeClass as any);
 96 |       
 97 |       expect(result.isAITool).toBe(true);
 98 |     });
 99 | 
100 |     it('should parse VersionedNodeType class', () => {
101 |       const versionedDef = versionedNodeClassFactory.build();
102 |       const VersionedNodeClass = class VersionedNodeType {
103 |         baseDescription = versionedDef.baseDescription;
104 |         nodeVersions = versionedDef.nodeVersions;
105 |         currentVersion = versionedDef.baseDescription!.defaultVersion;
106 |         
107 |         constructor() {
108 |           Object.defineProperty(this.constructor, 'name', {
109 |             value: 'VersionedNodeType',
110 |             configurable: true
111 |           });
112 |         }
113 |       };
114 |       
115 |       const result = parser.parse(VersionedNodeClass as any);
116 |       
117 |       expect(result.isVersioned).toBe(true);
118 |       expect(result.nodeType).toBe(versionedDef.baseDescription!.name);
119 |       expect(result.displayName).toBe(versionedDef.baseDescription!.displayName);
120 |       expect(result.version).toBe(versionedDef.baseDescription!.defaultVersion.toString());
121 |     });
122 | 
123 |     it('should merge baseDescription with version-specific description', () => {
124 |       const VersionedNodeClass = class VersionedNodeType {
125 |         baseDescription = {
126 |           name: 'mergedNode',
127 |           displayName: 'Base Display Name',
128 |           description: 'Base description'
129 |         };
130 |         
131 |         nodeVersions = {
132 |           1: {
133 |             description: {
134 |               displayName: 'Version 1 Display Name',
135 |               properties: [propertyFactory.build()]
136 |             }
137 |           }
138 |         };
139 |         
140 |         currentVersion = 1;
141 |         
142 |         constructor() {
143 |           Object.defineProperty(this.constructor, 'name', {
144 |             value: 'VersionedNodeType',
145 |             configurable: true
146 |           });
147 |         }
148 |       };
149 |       
150 |       const result = parser.parse(VersionedNodeClass as any);
151 |       
152 |       // Should merge baseDescription with version description
153 |       expect(result.nodeType).toBe('mergedNode'); // From base
154 |       expect(result.displayName).toBe('Version 1 Display Name'); // From version (overrides base)
155 |       expect(result.description).toBe('Base description'); // From base
156 |     });
157 | 
158 |     it('should throw error for nodes without name', () => {
159 |       const nodeDefinition = malformedNodeFactory.build();
160 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
161 |       
162 |       expect(() => parser.parse(NodeClass as any)).toThrow('Node is missing name property');
163 |     });
164 | 
165 |     it('should handle nodes that fail to instantiate', () => {
166 |       const NodeClass = class {
167 |         constructor() {
168 |           throw new Error('Cannot instantiate');
169 |         }
170 |       };
171 |       
172 |       expect(() => parser.parse(NodeClass as any)).toThrow('Node is missing name property');
173 |     });
174 | 
175 |     it('should handle static description property', () => {
176 |       const nodeDefinition = programmaticNodeFactory.build();
177 |       const NodeClass = class {
178 |         static description = nodeDefinition;
179 |       };
180 |       
181 |       // Since it can't instantiate and has no static description accessible,
182 |       // it should throw for missing name
183 |       expect(() => parser.parse(NodeClass as any)).toThrow();
184 |     });
185 | 
186 |     it('should handle instance-based nodes', () => {
187 |       const nodeDefinition = programmaticNodeFactory.build();
188 |       const nodeInstance = {
189 |         description: nodeDefinition
190 |       };
191 |       
192 |       const result = parser.parse(nodeInstance as any);
193 |       
194 |       expect(result.displayName).toBe(nodeDefinition.displayName);
195 |     });
196 | 
197 |     it('should use displayName fallback to name if not provided', () => {
198 |       const nodeDefinition = programmaticNodeFactory.build();
199 |       delete (nodeDefinition as any).displayName;
200 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
201 |       
202 |       const result = parser.parse(NodeClass as any);
203 |       
204 |       expect(result.displayName).toBe(nodeDefinition.name);
205 |     });
206 | 
207 |     it('should handle category extraction from different fields', () => {
208 |       const testCases = [
209 |         { 
210 |           description: { group: ['transform'], categories: ['output'] },
211 |           expected: 'transform' // group takes precedence
212 |         },
213 |         {
214 |           description: { categories: ['output'] },
215 |           expected: 'output'
216 |         },
217 |         {
218 |           description: {},
219 |           expected: undefined
220 |         }
221 |       ];
222 |       
223 |       testCases.forEach(({ description, expected }) => {
224 |         const baseDefinition = programmaticNodeFactory.build();
225 |         // Remove any existing group/categories from base definition to avoid conflicts
226 |         delete baseDefinition.group;
227 |         delete baseDefinition.categories;
228 |         
229 |         const nodeDefinition = { 
230 |           ...baseDefinition,
231 |           ...description,
232 |           name: baseDefinition.name // Ensure name is preserved
233 |         };
234 |         const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
235 |         
236 |         const result = parser.parse(NodeClass as any);
237 |         
238 |         expect(result.category).toBe(expected);
239 |       });
240 |     });
241 |   });
242 | 
243 |   describe('trigger detection', () => {
244 |     it('should detect triggers by group', () => {
245 |       const nodeDefinition = programmaticNodeFactory.build({
246 |         group: ['trigger']
247 |       });
248 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
249 |       
250 |       const result = parser.parse(NodeClass as any);
251 |       
252 |       expect(result.isTrigger).toBe(true);
253 |     });
254 | 
255 |     it('should detect polling triggers', () => {
256 |       const nodeDefinition = programmaticNodeFactory.build({
257 |         polling: true
258 |       });
259 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
260 |       
261 |       const result = parser.parse(NodeClass as any);
262 |       
263 |       expect(result.isTrigger).toBe(true);
264 |     });
265 | 
266 |     it('should detect trigger property', () => {
267 |       const nodeDefinition = programmaticNodeFactory.build({
268 |         trigger: true
269 |       });
270 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
271 |       
272 |       const result = parser.parse(NodeClass as any);
273 |       
274 |       expect(result.isTrigger).toBe(true);
275 |     });
276 | 
277 |     it('should detect event triggers', () => {
278 |       const nodeDefinition = programmaticNodeFactory.build({
279 |         eventTrigger: true
280 |       });
281 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
282 |       
283 |       const result = parser.parse(NodeClass as any);
284 |       
285 |       expect(result.isTrigger).toBe(true);
286 |     });
287 | 
288 |     it('should detect triggers by name', () => {
289 |       const nodeDefinition = programmaticNodeFactory.build({
290 |         name: 'customTrigger'
291 |       });
292 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
293 |       
294 |       const result = parser.parse(NodeClass as any);
295 |       
296 |       expect(result.isTrigger).toBe(true);
297 |     });
298 |   });
299 | 
300 |   describe('operations extraction', () => {
301 |     it('should extract declarative operations from routing.request', () => {
302 |       const nodeDefinition = declarativeNodeFactory.build();
303 |       // Fix the routing structure for simple parser
304 |       nodeDefinition.routing.request!.operation = {
305 |         options: [
306 |           { name: 'Create', value: 'create' },
307 |           { name: 'Get', value: 'get' }
308 |         ] as any
309 |       };
310 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
311 |       
312 |       const result = parser.parse(NodeClass as any);
313 |       
314 |       // Should have resource operations
315 |       const resourceOps = result.operations.filter(op => op.resource);
316 |       expect(resourceOps.length).toBeGreaterThan(0);
317 |       
318 |       // Should have operation entries
319 |       const operationOps = result.operations.filter(op => op.operation && !op.resource);
320 |       expect(operationOps.length).toBeGreaterThan(0);
321 |     });
322 | 
323 |     it('should extract declarative operations from routing.operations', () => {
324 |       const NodeClass = nodeClassFactory.build({
325 |         description: {
326 |           name: 'test',
327 |           routing: {
328 |             operations: {
329 |               create: { displayName: 'Create Item' },
330 |               read: { displayName: 'Read Item' },
331 |               update: { displayName: 'Update Item' },
332 |               delete: { displayName: 'Delete Item' }
333 |             }
334 |           }
335 |         }
336 |       });
337 |       
338 |       const result = parser.parse(NodeClass as any);
339 |       
340 |       expect(result.operations).toHaveLength(4);
341 |       expect(result.operations).toEqual(expect.arrayContaining([
342 |         { operation: 'create', name: 'Create Item' },
343 |         { operation: 'read', name: 'Read Item' },
344 |         { operation: 'update', name: 'Update Item' },
345 |         { operation: 'delete', name: 'Delete Item' }
346 |       ]));
347 |     });
348 | 
349 |     it('should extract programmatic operations from resource property', () => {
350 |       const resourceProp = resourcePropertyFactory.build();
351 |       const NodeClass = nodeClassFactory.build({
352 |         description: {
353 |           name: 'test',
354 |           properties: [resourceProp]
355 |         }
356 |       });
357 |       
358 |       const result = parser.parse(NodeClass as any);
359 |       
360 |       const resourceOps = result.operations.filter(op => op.type === 'resource');
361 |       expect(resourceOps).toHaveLength(resourceProp.options!.length);
362 |       resourceOps.forEach((op, idx) => {
363 |         expect(op).toMatchObject({
364 |           type: 'resource',
365 |           resource: resourceProp.options![idx].value,
366 |           name: resourceProp.options![idx].name
367 |         });
368 |       });
369 |     });
370 | 
371 |     it('should extract programmatic operations with resource context', () => {
372 |       const operationProp = operationPropertyFactory.build();
373 |       const NodeClass = nodeClassFactory.build({
374 |         description: {
375 |           name: 'test',
376 |           properties: [operationProp]
377 |         }
378 |       });
379 |       
380 |       const result = parser.parse(NodeClass as any);
381 |       
382 |       const operationOps = result.operations.filter(op => op.type === 'operation');
383 |       expect(operationOps).toHaveLength(operationProp.options!.length);
384 |       
385 |       // Should extract resource context from displayOptions
386 |       expect(operationOps[0].resources).toEqual(['user']);
387 |     });
388 | 
389 |     it('should handle operations with multiple resource conditions', () => {
390 |       const operationProp = {
391 |         name: 'operation',
392 |         type: 'options',
393 |         displayOptions: {
394 |           show: {
395 |             resource: ['user', 'post', 'comment']
396 |           }
397 |         },
398 |         options: [
399 |           { name: 'Create', value: 'create', action: 'Create item' }
400 |         ]
401 |       };
402 |       
403 |       const NodeClass = nodeClassFactory.build({
404 |         description: {
405 |           name: 'test',
406 |           properties: [operationProp]
407 |         }
408 |       });
409 |       
410 |       const result = parser.parse(NodeClass as any);
411 |       
412 |       const operationOps = result.operations.filter(op => op.type === 'operation');
413 |       expect(operationOps[0].resources).toEqual(['user', 'post', 'comment']);
414 |     });
415 | 
416 |     it('should handle single resource condition as array', () => {
417 |       const operationProp = {
418 |         name: 'operation',
419 |         type: 'options',
420 |         displayOptions: {
421 |           show: {
422 |             resource: 'user' // Single value, not array
423 |           }
424 |         },
425 |         options: [
426 |           { name: 'Get', value: 'get' }
427 |         ]
428 |       };
429 |       
430 |       const NodeClass = nodeClassFactory.build({
431 |         description: {
432 |           name: 'test',
433 |           properties: [operationProp]
434 |         }
435 |       });
436 |       
437 |       const result = parser.parse(NodeClass as any);
438 |       
439 |       const operationOps = result.operations.filter(op => op.type === 'operation');
440 |       expect(operationOps[0].resources).toEqual(['user']);
441 |     });
442 |   });
443 | 
444 |   describe('version extraction', () => {
445 |     it('should prioritize currentVersion over description.defaultVersion', () => {
446 |       const NodeClass = class {
447 |         currentVersion = 2.2;  // Should be returned
448 |         description = {
449 |           name: 'test',
450 |           displayName: 'Test',
451 |           defaultVersion: 3  // Should be ignored when currentVersion exists
452 |         };
453 |       };
454 | 
455 |       const result = parser.parse(NodeClass as any);
456 |       expect(result.version).toBe('2.2');
457 |     });
458 | 
459 |     it('should extract version from description.defaultVersion', () => {
460 |       const NodeClass = class {
461 |         description = {
462 |           name: 'test',
463 |           displayName: 'Test',
464 |           defaultVersion: 3
465 |         };
466 |       };
467 | 
468 |       const result = parser.parse(NodeClass as any);
469 |       expect(result.version).toBe('3');
470 |     });
471 | 
472 |     it('should NOT extract version from non-existent baseDescription (legacy bug)', () => {
473 |       // This test verifies the bug fix from v2.17.4
474 |       // baseDescription.defaultVersion doesn't exist on VersionedNodeType instances
475 |       const NodeClass = class {
476 |         baseDescription = {  // This property doesn't exist on VersionedNodeType!
477 |           name: 'test',
478 |           displayName: 'Test',
479 |           defaultVersion: 3
480 |         };
481 |         // Constructor name trick to detect as VersionedNodeType
482 |         constructor() {
483 |           Object.defineProperty(this.constructor, 'name', {
484 |             value: 'VersionedNodeType',
485 |             configurable: true
486 |           });
487 |         }
488 |       };
489 | 
490 |       const result = parser.parse(NodeClass as any);
491 | 
492 |       // Should fallback to default version '1' since baseDescription.defaultVersion doesn't exist
493 |       expect(result.version).toBe('1');
494 |     });
495 | 
496 |     it('should extract version from description.version', () => {
497 |       // For this test, the version needs to be in the instantiated description
498 |       const NodeClass = class {
499 |         description = {
500 |           name: 'test',
501 |           version: 2
502 |         };
503 |       };
504 |       
505 |       const result = parser.parse(NodeClass as any);
506 |       
507 |       expect(result.version).toBe('2');
508 |     });
509 | 
510 |     it('should default to version 1', () => {
511 |       const NodeClass = nodeClassFactory.build({
512 |         description: {
513 |           name: 'test'
514 |         }
515 |       });
516 |       
517 |       const result = parser.parse(NodeClass as any);
518 |       
519 |       expect(result.version).toBe('1');
520 |     });
521 |   });
522 | 
523 |   describe('versioned node detection', () => {
524 |     it('should detect nodes with baseDescription and nodeVersions', () => {
525 |       // For simple parser, need to create a proper class structure
526 |       const NodeClass = class {
527 |         baseDescription = { 
528 |           name: 'test',
529 |           displayName: 'Test' 
530 |         };
531 |         nodeVersions = { 1: {}, 2: {} };
532 |         
533 |         constructor() {
534 |           Object.defineProperty(this.constructor, 'name', {
535 |             value: 'VersionedNodeType',
536 |             configurable: true
537 |           });
538 |         }
539 |       };
540 |       
541 |       const result = parser.parse(NodeClass as any);
542 |       
543 |       expect(result.isVersioned).toBe(true);
544 |     });
545 | 
546 |     it('should detect nodes with version array', () => {
547 |       const NodeClass = nodeClassFactory.build({
548 |         description: {
549 |           name: 'test',
550 |           version: [1, 1.1, 2]
551 |         }
552 |       });
553 |       
554 |       const result = parser.parse(NodeClass as any);
555 |       
556 |       expect(result.isVersioned).toBe(true);
557 |     });
558 | 
559 |     it('should detect nodes with defaultVersion', () => {
560 |       const NodeClass = nodeClassFactory.build({
561 |         description: {
562 |           name: 'test',
563 |           defaultVersion: 2
564 |         }
565 |       });
566 |       
567 |       const result = parser.parse(NodeClass as any);
568 |       
569 |       expect(result.isVersioned).toBe(true);
570 |     });
571 | 
572 |     it('should handle instance-level version detection', () => {
573 |       const NodeClass = class {
574 |         description = {
575 |           name: 'test',
576 |           version: [1, 2, 3]
577 |         };
578 |       };
579 |       
580 |       const result = parser.parse(NodeClass as any);
581 |       
582 |       expect(result.isVersioned).toBe(true);
583 |     });
584 |   });
585 | 
586 |   describe('edge cases', () => {
587 |     it('should handle empty routing object', () => {
588 |       const NodeClass = nodeClassFactory.build({
589 |         description: {
590 |           name: 'test',
591 |           routing: {}
592 |         }
593 |       });
594 |       
595 |       const result = parser.parse(NodeClass as any);
596 |       
597 |       expect(result.style).toBe('declarative');
598 |       expect(result.operations).toEqual([]);
599 |     });
600 | 
601 |     it('should handle missing properties array', () => {
602 |       const NodeClass = nodeClassFactory.build({
603 |         description: {
604 |           name: 'test'
605 |         }
606 |       });
607 |       
608 |       const result = parser.parse(NodeClass as any);
609 |       
610 |       expect(result.properties).toEqual([]);
611 |     });
612 | 
613 |     it('should handle missing credentials', () => {
614 |       const nodeDefinition = programmaticNodeFactory.build();
615 |       delete (nodeDefinition as any).credentials;
616 |       const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
617 |       
618 |       const result = parser.parse(NodeClass as any);
619 |       
620 |       expect(result.credentials).toEqual([]);
621 |     });
622 | 
623 |     it('should handle nodes with baseDescription but no name in main description', () => {
624 |       const NodeClass = class {
625 |         description = {};
626 |         baseDescription = {
627 |           name: 'baseNode',
628 |           displayName: 'Base Node'
629 |         };
630 |       };
631 |       
632 |       const result = parser.parse(NodeClass as any);
633 |       
634 |       expect(result.nodeType).toBe('baseNode');
635 |       expect(result.displayName).toBe('Base Node');
636 |     });
637 | 
638 |     it('should handle complex nested routing structures', () => {
639 |       const NodeClass = nodeClassFactory.build({
640 |         description: {
641 |           name: 'test',
642 |           routing: {
643 |             request: {
644 |               resource: {
645 |                 options: []
646 |               },
647 |               operation: {
648 |                 options: [] // Should be array, not object
649 |               }
650 |             },
651 |             operations: {}
652 |           }
653 |         }
654 |       });
655 |       
656 |       const result = parser.parse(NodeClass as any);
657 |       
658 |       expect(result.operations).toEqual([]);
659 |     });
660 | 
661 |     it('should handle operations without displayName', () => {
662 |       const NodeClass = nodeClassFactory.build({
663 |         description: {
664 |           name: 'test',
665 |           properties: [
666 |             {
667 |               name: 'operation',
668 |               type: 'options',
669 |               displayOptions: {
670 |                 show: {}
671 |               },
672 |               options: [
673 |                 { value: 'create' }, // No name field
674 |                 { value: 'update', name: 'Update' }
675 |               ]
676 |             }
677 |           ]
678 |         }
679 |       });
680 |       
681 |       const result = parser.parse(NodeClass as any);
682 |       
683 |       // Should handle missing names gracefully
684 |       expect(result.operations).toHaveLength(2);
685 |     });
686 |   });
687 | });
```

--------------------------------------------------------------------------------
/tests/integration/mcp-protocol/performance.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  2 | import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
  3 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
  4 | import { TestableN8NMCPServer } from './test-helpers';
  5 | 
  6 | describe('MCP Performance Tests', () => {
  7 |   let mcpServer: TestableN8NMCPServer;
  8 |   let client: Client;
  9 | 
 10 |   beforeEach(async () => {
 11 |     mcpServer = new TestableN8NMCPServer();
 12 |     await mcpServer.initialize();
 13 |     
 14 |     const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
 15 |     await mcpServer.connectToTransport(serverTransport);
 16 |     
 17 |     client = new Client({
 18 |       name: 'test-client',
 19 |       version: '1.0.0'
 20 |     }, {
 21 |       capabilities: {}
 22 |     });
 23 |     
 24 |     await client.connect(clientTransport);
 25 |     
 26 |     // Verify database is populated by checking statistics
 27 |     const statsResponse = await client.callTool({ name: 'get_database_statistics', arguments: {} });
 28 |     if ((statsResponse as any).content && (statsResponse as any).content[0]) {
 29 |       const stats = JSON.parse((statsResponse as any).content[0].text);
 30 |       // Ensure database has nodes for testing
 31 |       if (!stats.totalNodes || stats.totalNodes === 0) {
 32 |         console.error('Database stats:', stats);
 33 |         throw new Error('Test database not properly populated');
 34 |       }
 35 |     }
 36 |   });
 37 | 
 38 |   afterEach(async () => {
 39 |     await client.close();
 40 |     await mcpServer.close();
 41 |   });
 42 | 
 43 |   describe('Response Time Benchmarks', () => {
 44 |     it('should respond to simple queries quickly', async () => {
 45 |       const iterations = 100;
 46 |       const start = performance.now();
 47 | 
 48 |       for (let i = 0; i < iterations; i++) {
 49 |         await client.callTool({ name: 'get_database_statistics', arguments: {} });
 50 |       }
 51 | 
 52 |       const duration = performance.now() - start;
 53 |       const avgTime = duration / iterations;
 54 | 
 55 |       console.log(`Average response time for get_database_statistics: ${avgTime.toFixed(2)}ms`);
 56 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
 57 | 
 58 |       // Environment-aware threshold (relaxed +20% for type safety overhead)
 59 |       const threshold = process.env.CI ? 20 : 12;
 60 |       expect(avgTime).toBeLessThan(threshold);
 61 |     });
 62 | 
 63 |     it('should handle list operations efficiently', async () => {
 64 |       const iterations = 50;
 65 |       const start = performance.now();
 66 | 
 67 |       for (let i = 0; i < iterations; i++) {
 68 |         await client.callTool({ name: 'list_nodes', arguments: { limit: 10 } });
 69 |       }
 70 | 
 71 |       const duration = performance.now() - start;
 72 |       const avgTime = duration / iterations;
 73 | 
 74 |       console.log(`Average response time for list_nodes: ${avgTime.toFixed(2)}ms`);
 75 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
 76 |       
 77 |       // Environment-aware threshold
 78 |       const threshold = process.env.CI ? 40 : 20;
 79 |       expect(avgTime).toBeLessThan(threshold);
 80 |     });
 81 | 
 82 |     it('should perform searches efficiently', async () => {
 83 |       const searches = ['http', 'webhook', 'slack', 'database', 'api'];
 84 |       const iterations = 20;
 85 |       const start = performance.now();
 86 | 
 87 |       for (let i = 0; i < iterations; i++) {
 88 |         for (const query of searches) {
 89 |           await client.callTool({ name: 'search_nodes', arguments: { query } });
 90 |         }
 91 |       }
 92 | 
 93 |       const totalRequests = iterations * searches.length;
 94 |       const duration = performance.now() - start;
 95 |       const avgTime = duration / totalRequests;
 96 | 
 97 |       console.log(`Average response time for search_nodes: ${avgTime.toFixed(2)}ms`);
 98 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
 99 |       
100 |       // Environment-aware threshold
101 |       const threshold = process.env.CI ? 60 : 30;
102 |       expect(avgTime).toBeLessThan(threshold);
103 |     });
104 | 
105 |     it('should retrieve node info quickly', async () => {
106 |       const nodeTypes = [
107 |         'nodes-base.httpRequest',
108 |         'nodes-base.webhook',
109 |         'nodes-base.set',
110 |         'nodes-base.if',
111 |         'nodes-base.switch'
112 |       ];
113 | 
114 |       const start = performance.now();
115 | 
116 |       for (const nodeType of nodeTypes) {
117 |         await client.callTool({ name: 'get_node_info', arguments: { nodeType } });
118 |       }
119 | 
120 |       const duration = performance.now() - start;
121 |       const avgTime = duration / nodeTypes.length;
122 | 
123 |       console.log(`Average response time for get_node_info: ${avgTime.toFixed(2)}ms`);
124 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
125 |       
126 |       // Environment-aware threshold (these are large responses)
127 |       const threshold = process.env.CI ? 100 : 50;
128 |       expect(avgTime).toBeLessThan(threshold);
129 |     });
130 |   });
131 | 
132 |   describe('Concurrent Request Performance', () => {
133 |     it('should handle concurrent requests efficiently', async () => {
134 |       const concurrentRequests = 50;
135 |       const start = performance.now();
136 | 
137 |       const promises = [];
138 |       for (let i = 0; i < concurrentRequests; i++) {
139 |         promises.push(
140 |           client.callTool({ name: 'list_nodes', arguments: { limit: 5 } })
141 |         );
142 |       }
143 | 
144 |       await Promise.all(promises);
145 | 
146 |       const duration = performance.now() - start;
147 |       const avgTime = duration / concurrentRequests;
148 | 
149 |       console.log(`Average time for ${concurrentRequests} concurrent requests: ${avgTime.toFixed(2)}ms`);
150 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
151 |       
152 |       // Concurrent requests should be more efficient than sequential
153 |       const threshold = process.env.CI ? 25 : 10;
154 |       expect(avgTime).toBeLessThan(threshold);
155 |     });
156 | 
157 |     it('should handle mixed concurrent operations', async () => {
158 |       const operations = [
159 |         { tool: 'list_nodes', params: { limit: 10 } },
160 |         { tool: 'search_nodes', params: { query: 'http' } },
161 |         { tool: 'get_database_statistics', params: {} },
162 |         { tool: 'list_ai_tools', params: {} },
163 |         { tool: 'list_tasks', params: {} }
164 |       ];
165 | 
166 |       const rounds = 10;
167 |       const start = performance.now();
168 | 
169 |       for (let round = 0; round < rounds; round++) {
170 |         const promises = operations.map(op => 
171 |           client.callTool({ name: op.tool, arguments: op.params })
172 |         );
173 |         await Promise.all(promises);
174 |       }
175 | 
176 |       const duration = performance.now() - start;
177 |       const totalRequests = rounds * operations.length;
178 |       const avgTime = duration / totalRequests;
179 | 
180 |       console.log(`Average time for mixed operations: ${avgTime.toFixed(2)}ms`);
181 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
182 |       
183 |       const threshold = process.env.CI ? 40 : 20;
184 |       expect(avgTime).toBeLessThan(threshold);
185 |     });
186 |   });
187 | 
188 |   describe('Large Data Performance', () => {
189 |     it('should handle large node lists efficiently', async () => {
190 |       const start = performance.now();
191 | 
192 |       const response = await client.callTool({ name: 'list_nodes', arguments: {
193 |         limit: 200 // Get many nodes
194 |       } });
195 | 
196 |       const duration = performance.now() - start;
197 | 
198 |       console.log(`Time to list 200 nodes: ${duration.toFixed(2)}ms`);
199 |       
200 |       // Environment-aware threshold
201 |       const threshold = process.env.CI ? 200 : 100;
202 |       expect(duration).toBeLessThan(threshold);
203 | 
204 |       // Check the response content
205 |       expect(response).toBeDefined();
206 |       
207 |       let nodes;
208 |       if (response.content && Array.isArray(response.content) && response.content[0]) {
209 |         // MCP standard response format
210 |         expect(response.content[0].type).toBe('text');
211 |         expect(response.content[0].text).toBeDefined();
212 |         
213 |         try {
214 |           const parsed = JSON.parse(response.content[0].text);
215 |           // list_nodes returns an object with nodes property
216 |           nodes = parsed.nodes || parsed;
217 |         } catch (e) {
218 |           console.error('Failed to parse JSON:', e);
219 |           console.error('Response text was:', response.content[0].text);
220 |           throw e;
221 |         }
222 |       } else if (Array.isArray(response)) {
223 |         // Direct array response
224 |         nodes = response;
225 |       } else if (response.nodes) {
226 |         // Object with nodes property
227 |         nodes = response.nodes;
228 |       } else {
229 |         console.error('Unexpected response format:', response);
230 |         throw new Error('Unexpected response format');
231 |       }
232 |       
233 |       expect(nodes).toBeDefined();
234 |       expect(Array.isArray(nodes)).toBe(true);
235 |       expect(nodes.length).toBeGreaterThan(100);
236 |     });
237 | 
238 |     it('should handle large workflow validation efficiently', async () => {
239 |       // Create a large workflow
240 |       const nodeCount = 100;
241 |       const nodes = [];
242 |       const connections: any = {};
243 | 
244 |       for (let i = 0; i < nodeCount; i++) {
245 |         nodes.push({
246 |           id: String(i),
247 |           name: `Node${i}`,
248 |           type: i % 3 === 0 ? 'nodes-base.httpRequest' : 'nodes-base.set',
249 |           typeVersion: 1,
250 |           position: [i * 100, 0],
251 |           parameters: i % 3 === 0 ? 
252 |             { method: 'GET', url: 'https://api.example.com' } :
253 |             { values: { string: [{ name: 'test', value: 'value' }] } }
254 |         });
255 | 
256 |         if (i > 0) {
257 |           connections[`Node${i-1}`] = {
258 |             'main': [[{ node: `Node${i}`, type: 'main', index: 0 }]]
259 |           };
260 |         }
261 |       }
262 | 
263 |       const start = performance.now();
264 | 
265 |       const response = await client.callTool({ name: 'validate_workflow', arguments: {
266 |         workflow: { nodes, connections }
267 |       } });
268 | 
269 |       const duration = performance.now() - start;
270 | 
271 |       console.log(`Time to validate ${nodeCount} node workflow: ${duration.toFixed(2)}ms`);
272 |       
273 |       // Environment-aware threshold
274 |       const threshold = process.env.CI ? 1000 : 500;
275 |       expect(duration).toBeLessThan(threshold);
276 | 
277 |       // Check the response content - MCP callTool returns content array with text
278 |       expect(response).toBeDefined();
279 |       expect((response as any).content).toBeDefined();
280 |       expect(Array.isArray((response as any).content)).toBe(true);
281 |       expect((response as any).content.length).toBeGreaterThan(0);
282 |       expect((response as any).content[0]).toBeDefined();
283 |       expect((response as any).content[0].type).toBe('text');
284 |       expect((response as any).content[0].text).toBeDefined();
285 |       
286 |       // Parse the JSON response
287 |       const validation = JSON.parse((response as any).content[0].text);
288 |       
289 |       expect(validation).toBeDefined();
290 |       expect(validation).toHaveProperty('valid');
291 |     });
292 |   });
293 | 
294 |   describe('Memory Efficiency', () => {
295 |     it('should handle repeated operations without memory leaks', async () => {
296 |       const iterations = 1000;
297 |       const batchSize = 100;
298 | 
299 |       // Measure initial memory if available
300 |       const initialMemory = process.memoryUsage();
301 | 
302 |       for (let i = 0; i < iterations; i += batchSize) {
303 |         const promises = [];
304 |         
305 |         for (let j = 0; j < batchSize; j++) {
306 |           promises.push(
307 |             client.callTool({ name: 'get_database_statistics', arguments: {} })
308 |           );
309 |         }
310 | 
311 |         await Promise.all(promises);
312 | 
313 |         // Force garbage collection if available
314 |         if (global.gc) {
315 |           global.gc();
316 |         }
317 |       }
318 | 
319 |       const finalMemory = process.memoryUsage();
320 |       const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed;
321 | 
322 |       console.log(`Memory increase after ${iterations} operations: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`);
323 | 
324 |       // Memory increase should be reasonable (less than 50MB)
325 |       expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024);
326 |     });
327 | 
328 |     it('should release memory after large operations', async () => {
329 |       const initialMemory = process.memoryUsage();
330 | 
331 |       // Perform large operations
332 |       for (let i = 0; i < 10; i++) {
333 |         await client.callTool({ name: 'list_nodes', arguments: { limit: 200 } });
334 |         await client.callTool({ name: 'get_node_info', arguments: { 
335 |           nodeType: 'nodes-base.httpRequest' 
336 |         } });
337 |       }
338 | 
339 |       // Force garbage collection if available
340 |       if (global.gc) {
341 |         global.gc();
342 |         await new Promise(resolve => setTimeout(resolve, 100));
343 |       }
344 | 
345 |       const finalMemory = process.memoryUsage();
346 |       const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed;
347 | 
348 |       console.log(`Memory increase after large operations: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`);
349 | 
350 |       // Should not retain excessive memory
351 |       expect(memoryIncrease).toBeLessThan(20 * 1024 * 1024);
352 |     });
353 |   });
354 | 
355 |   describe('Scalability Tests', () => {
356 |     it('should maintain performance with increasing load', async () => {
357 |       const loadLevels = [10, 50, 100, 200];
358 |       const results: any[] = [];
359 | 
360 |       for (const load of loadLevels) {
361 |         const start = performance.now();
362 |         
363 |         const promises = [];
364 |         for (let i = 0; i < load; i++) {
365 |           promises.push(
366 |             client.callTool({ name: 'list_nodes', arguments: { limit: 1 } })
367 |           );
368 |         }
369 | 
370 |         await Promise.all(promises);
371 |         
372 |         const duration = performance.now() - start;
373 |         const avgTime = duration / load;
374 | 
375 |         results.push({
376 |           load,
377 |           totalTime: duration,
378 |           avgTime
379 |         });
380 | 
381 |         console.log(`Load ${load}: Total ${duration.toFixed(2)}ms, Avg ${avgTime.toFixed(2)}ms`);
382 |       }
383 | 
384 |       // Average time should not increase dramatically with load
385 |       const firstAvg = results[0].avgTime;
386 |       const lastAvg = results[results.length - 1].avgTime;
387 |       
388 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
389 |       console.log(`Performance scaling - First avg: ${firstAvg.toFixed(2)}ms, Last avg: ${lastAvg.toFixed(2)}ms`);
390 |       
391 |       // Environment-aware scaling factor
392 |       const scalingFactor = process.env.CI ? 3 : 2;
393 |       expect(lastAvg).toBeLessThan(firstAvg * scalingFactor);
394 |     });
395 | 
396 |     it('should handle burst traffic', async () => {
397 |       const burstSize = 100;
398 |       const start = performance.now();
399 | 
400 |       // Simulate burst of requests
401 |       const promises = [];
402 |       for (let i = 0; i < burstSize; i++) {
403 |         const operation = i % 4;
404 |         switch (operation) {
405 |           case 0:
406 |             promises.push(client.callTool({ name: 'list_nodes', arguments: { limit: 5 } }));
407 |             break;
408 |           case 1:
409 |             promises.push(client.callTool({ name: 'search_nodes', arguments: { query: 'test' } }));
410 |             break;
411 |           case 2:
412 |             promises.push(client.callTool({ name: 'get_database_statistics', arguments: {} }));
413 |             break;
414 |           case 3:
415 |             promises.push(client.callTool({ name: 'list_ai_tools', arguments: {} }));
416 |             break;
417 |         }
418 |       }
419 | 
420 |       await Promise.all(promises);
421 | 
422 |       const duration = performance.now() - start;
423 | 
424 |       console.log(`Burst of ${burstSize} requests completed in ${duration.toFixed(2)}ms`);
425 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
426 | 
427 |       // Should handle burst within reasonable time
428 |       const threshold = process.env.CI ? 2000 : 1000;
429 |       expect(duration).toBeLessThan(threshold);
430 |     });
431 |   });
432 | 
433 |   describe('Critical Path Optimization', () => {
434 |     it('should optimize tool listing performance', async () => {
435 |       // Warm up with multiple calls to ensure everything is initialized
436 |       for (let i = 0; i < 5; i++) {
437 |         await client.callTool({ name: 'list_nodes', arguments: { limit: 1 } });
438 |       }
439 | 
440 |       const iterations = 100;
441 |       const times: number[] = [];
442 | 
443 |       for (let i = 0; i < iterations; i++) {
444 |         const start = performance.now();
445 |         await client.callTool({ name: 'list_nodes', arguments: { limit: 20 } });
446 |         times.push(performance.now() - start);
447 |       }
448 | 
449 |       // Remove outliers (first few runs might be slower)
450 |       times.sort((a, b) => a - b);
451 |       const trimmedTimes = times.slice(10, -10); // Remove top and bottom 10%
452 |       
453 |       const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length;
454 |       const minTime = Math.min(...trimmedTimes);
455 |       const maxTime = Math.max(...trimmedTimes);
456 | 
457 |       console.log(`list_nodes performance - Avg: ${avgTime.toFixed(2)}ms, Min: ${minTime.toFixed(2)}ms, Max: ${maxTime.toFixed(2)}ms`);
458 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
459 | 
460 |       // Environment-aware thresholds
461 |       const threshold = process.env.CI ? 25 : 10;
462 |       expect(avgTime).toBeLessThan(threshold);
463 |       
464 |       // Max should not be too much higher than average (no outliers)
465 |       // More lenient in CI due to resource contention
466 |       const maxMultiplier = process.env.CI ? 5 : 3;
467 |       expect(maxTime).toBeLessThan(avgTime * maxMultiplier);
468 |     });
469 | 
470 |     it('should optimize search performance', async () => {
471 |       // Warm up with multiple calls
472 |       for (let i = 0; i < 3; i++) {
473 |         await client.callTool({ name: 'search_nodes', arguments: { query: 'test' } });
474 |       }
475 | 
476 |       const queries = ['http', 'webhook', 'database', 'api', 'slack'];
477 |       const times: number[] = [];
478 | 
479 |       for (const query of queries) {
480 |         for (let i = 0; i < 20; i++) {
481 |           const start = performance.now();
482 |           await client.callTool({ name: 'search_nodes', arguments: { query } });
483 |           times.push(performance.now() - start);
484 |         }
485 |       }
486 | 
487 |       // Remove outliers
488 |       times.sort((a, b) => a - b);
489 |       const trimmedTimes = times.slice(10, -10); // Remove top and bottom 10%
490 |       
491 |       const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length;
492 | 
493 |       console.log(`search_nodes average performance: ${avgTime.toFixed(2)}ms`);
494 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
495 | 
496 |       // Environment-aware threshold
497 |       const threshold = process.env.CI ? 35 : 15;
498 |       expect(avgTime).toBeLessThan(threshold);
499 |     });
500 | 
501 |     it('should cache effectively for repeated queries', async () => {
502 |       const nodeType = 'nodes-base.httpRequest';
503 | 
504 |       // First call (cold)
505 |       const coldStart = performance.now();
506 |       await client.callTool({ name: 'get_node_info', arguments: { nodeType } });
507 |       const coldTime = performance.now() - coldStart;
508 | 
509 |       // Give cache time to settle
510 |       await new Promise(resolve => setTimeout(resolve, 10));
511 | 
512 |       // Subsequent calls (potentially cached)
513 |       const warmTimes: number[] = [];
514 |       for (let i = 0; i < 10; i++) {
515 |         const start = performance.now();
516 |         await client.callTool({ name: 'get_node_info', arguments: { nodeType } });
517 |         warmTimes.push(performance.now() - start);
518 |       }
519 | 
520 |       // Remove outliers from warm times
521 |       warmTimes.sort((a, b) => a - b);
522 |       const trimmedWarmTimes = warmTimes.slice(1, -1); // Remove highest and lowest
523 |       const avgWarmTime = trimmedWarmTimes.reduce((a, b) => a + b, 0) / trimmedWarmTimes.length;
524 | 
525 |       console.log(`Cold time: ${coldTime.toFixed(2)}ms, Avg warm time: ${avgWarmTime.toFixed(2)}ms`);
526 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
527 | 
528 |       // In CI, caching might not be as effective due to resource constraints
529 |       const cacheMultiplier = process.env.CI ? 1.5 : 1.1;
530 |       
531 |       // Warm calls should be faster or at least not significantly slower
532 |       expect(avgWarmTime).toBeLessThanOrEqual(coldTime * cacheMultiplier);
533 |     });
534 |   });
535 | 
536 |   describe('Stress Tests', () => {
537 |     it('should handle sustained high load', async () => {
538 |       const duration = 5000; // 5 seconds
539 |       const start = performance.now();
540 |       let requestCount = 0;
541 |       let errorCount = 0;
542 | 
543 |       while (performance.now() - start < duration) {
544 |         try {
545 |           await client.callTool({ name: 'get_database_statistics', arguments: {} });
546 |           requestCount++;
547 |         } catch (error) {
548 |           errorCount++;
549 |         }
550 |       }
551 | 
552 |       const actualDuration = performance.now() - start;
553 |       const requestsPerSecond = requestCount / (actualDuration / 1000);
554 | 
555 |       console.log(`Sustained load test - Requests: ${requestCount}, RPS: ${requestsPerSecond.toFixed(2)}, Errors: ${errorCount}`);
556 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
557 | 
558 |       // Environment-aware RPS threshold (relaxed -8% for type safety overhead)
559 |       const rpsThreshold = process.env.CI ? 50 : 92;
560 |       expect(requestsPerSecond).toBeGreaterThan(rpsThreshold);
561 |       
562 |       // Error rate should be very low
563 |       expect(errorCount).toBe(0);
564 |     });
565 | 
566 |     it('should recover from performance degradation', async () => {
567 |       // Create heavy load
568 |       const heavyPromises = [];
569 |       for (let i = 0; i < 200; i++) {
570 |         heavyPromises.push(
571 |           client.callTool({ name: 'validate_workflow', arguments: {
572 |             workflow: {
573 |               nodes: Array(20).fill(null).map((_, idx) => ({
574 |                 id: String(idx),
575 |                 name: `Node${idx}`,
576 |                 type: 'nodes-base.set',
577 |                 typeVersion: 1,
578 |                 position: [idx * 100, 0],
579 |                 parameters: {}
580 |               })),
581 |               connections: {}
582 |             }
583 |           } })
584 |         );
585 |       }
586 | 
587 |       await Promise.all(heavyPromises);
588 | 
589 |       // Measure performance after heavy load
590 |       const recoveryTimes: number[] = [];
591 |       for (let i = 0; i < 10; i++) {
592 |         const start = performance.now();
593 |         await client.callTool({ name: 'get_database_statistics', arguments: {} });
594 |         recoveryTimes.push(performance.now() - start);
595 |       }
596 | 
597 |       const avgRecoveryTime = recoveryTimes.reduce((a, b) => a + b, 0) / recoveryTimes.length;
598 | 
599 |       console.log(`Average response time after heavy load: ${avgRecoveryTime.toFixed(2)}ms`);
600 |       console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`);
601 | 
602 |       // Should recover to normal performance (relaxed +20% for type safety overhead)
603 |       const threshold = process.env.CI ? 25 : 12;
604 |       expect(avgRecoveryTime).toBeLessThan(threshold);
605 |     });
606 |   });
607 | });
```

--------------------------------------------------------------------------------
/tests/unit/services/ai-node-validator.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect } from 'vitest';
  2 | import {
  3 |   validateAIAgent,
  4 |   validateChatTrigger,
  5 |   validateBasicLLMChain,
  6 |   buildReverseConnectionMap,
  7 |   getAIConnections,
  8 |   validateAISpecificNodes,
  9 |   type WorkflowNode,
 10 |   type WorkflowJson
 11 | } from '@/services/ai-node-validator';
 12 | 
 13 | describe('AI Node Validator', () => {
 14 |   describe('buildReverseConnectionMap', () => {
 15 |     it('should build reverse connections for AI language model', () => {
 16 |       const workflow: WorkflowJson = {
 17 |         nodes: [],
 18 |         connections: {
 19 |           'OpenAI': {
 20 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
 21 |           }
 22 |         }
 23 |       };
 24 | 
 25 |       const reverseMap = buildReverseConnectionMap(workflow);
 26 | 
 27 |       expect(reverseMap.get('AI Agent')).toEqual([
 28 |         {
 29 |           sourceName: 'OpenAI',
 30 |           sourceType: 'ai_languageModel',
 31 |           type: 'ai_languageModel',
 32 |           index: 0
 33 |         }
 34 |       ]);
 35 |     });
 36 | 
 37 |     it('should handle multiple AI connections to same node', () => {
 38 |       const workflow: WorkflowJson = {
 39 |         nodes: [],
 40 |         connections: {
 41 |           'OpenAI': {
 42 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
 43 |           },
 44 |           'HTTP Request Tool': {
 45 |             'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
 46 |           },
 47 |           'Window Buffer Memory': {
 48 |             'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
 49 |           }
 50 |         }
 51 |       };
 52 | 
 53 |       const reverseMap = buildReverseConnectionMap(workflow);
 54 |       const agentConnections = reverseMap.get('AI Agent');
 55 | 
 56 |       expect(agentConnections).toHaveLength(3);
 57 |       expect(agentConnections).toContainEqual(
 58 |         expect.objectContaining({ type: 'ai_languageModel' })
 59 |       );
 60 |       expect(agentConnections).toContainEqual(
 61 |         expect.objectContaining({ type: 'ai_tool' })
 62 |       );
 63 |       expect(agentConnections).toContainEqual(
 64 |         expect.objectContaining({ type: 'ai_memory' })
 65 |       );
 66 |     });
 67 | 
 68 |     it('should skip empty source names', () => {
 69 |       const workflow: WorkflowJson = {
 70 |         nodes: [],
 71 |         connections: {
 72 |           '': {
 73 |             'main': [[{ node: 'Target', type: 'main', index: 0 }]]
 74 |           }
 75 |         }
 76 |       };
 77 | 
 78 |       const reverseMap = buildReverseConnectionMap(workflow);
 79 | 
 80 |       expect(reverseMap.has('Target')).toBe(false);
 81 |     });
 82 | 
 83 |     it('should skip empty target node names', () => {
 84 |       const workflow: WorkflowJson = {
 85 |         nodes: [],
 86 |         connections: {
 87 |           'Source': {
 88 |             'main': [[{ node: '', type: 'main', index: 0 }]]
 89 |           }
 90 |         }
 91 |       };
 92 | 
 93 |       const reverseMap = buildReverseConnectionMap(workflow);
 94 | 
 95 |       expect(reverseMap.size).toBe(0);
 96 |     });
 97 |   });
 98 | 
 99 |   describe('getAIConnections', () => {
100 |     it('should filter AI connections from all incoming connections', () => {
101 |       const reverseMap = new Map();
102 |       reverseMap.set('AI Agent', [
103 |         { sourceName: 'Chat Trigger', type: 'main', index: 0 },
104 |         { sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
105 |         { sourceName: 'HTTP Tool', type: 'ai_tool', index: 0 }
106 |       ]);
107 | 
108 |       const aiConnections = getAIConnections('AI Agent', reverseMap);
109 | 
110 |       expect(aiConnections).toHaveLength(2);
111 |       expect(aiConnections).not.toContainEqual(
112 |         expect.objectContaining({ type: 'main' })
113 |       );
114 |     });
115 | 
116 |     it('should filter by specific AI connection type', () => {
117 |       const reverseMap = new Map();
118 |       reverseMap.set('AI Agent', [
119 |         { sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
120 |         { sourceName: 'Tool1', type: 'ai_tool', index: 0 },
121 |         { sourceName: 'Tool2', type: 'ai_tool', index: 1 }
122 |       ]);
123 | 
124 |       const toolConnections = getAIConnections('AI Agent', reverseMap, 'ai_tool');
125 | 
126 |       expect(toolConnections).toHaveLength(2);
127 |       expect(toolConnections.every(c => c.type === 'ai_tool')).toBe(true);
128 |     });
129 | 
130 |     it('should return empty array for node with no connections', () => {
131 |       const reverseMap = new Map();
132 | 
133 |       const connections = getAIConnections('Unknown Node', reverseMap);
134 | 
135 |       expect(connections).toEqual([]);
136 |     });
137 |   });
138 | 
139 |   describe('validateAIAgent', () => {
140 |     it('should error on missing language model connection', () => {
141 |       const node: WorkflowNode = {
142 |         id: 'agent1',
143 |         name: 'AI Agent',
144 |         type: '@n8n/n8n-nodes-langchain.agent',
145 |         position: [0, 0],
146 |         parameters: {}
147 |       };
148 | 
149 |       const workflow: WorkflowJson = {
150 |         nodes: [node],
151 |         connections: {}
152 |       };
153 | 
154 |       const reverseMap = buildReverseConnectionMap(workflow);
155 |       const issues = validateAIAgent(node, reverseMap, workflow);
156 | 
157 |       expect(issues).toContainEqual(
158 |         expect.objectContaining({
159 |           severity: 'error',
160 |           message: expect.stringContaining('language model')
161 |         })
162 |       );
163 |     });
164 | 
165 |     it('should accept single language model connection', () => {
166 |       const agent: WorkflowNode = {
167 |         id: 'agent1',
168 |         name: 'AI Agent',
169 |         type: '@n8n/n8n-nodes-langchain.agent',
170 |         position: [0, 0],
171 |         parameters: { promptType: 'auto' }
172 |       };
173 | 
174 |       const model: WorkflowNode = {
175 |         id: 'llm1',
176 |         name: 'OpenAI',
177 |         type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
178 |         position: [0, -100],
179 |         parameters: {}
180 |       };
181 | 
182 |       const workflow: WorkflowJson = {
183 |         nodes: [agent, model],
184 |         connections: {
185 |           'OpenAI': {
186 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
187 |           }
188 |         }
189 |       };
190 | 
191 |       const reverseMap = buildReverseConnectionMap(workflow);
192 |       const issues = validateAIAgent(agent, reverseMap, workflow);
193 | 
194 |       const languageModelErrors = issues.filter(i =>
195 |         i.severity === 'error' && i.message.includes('language model')
196 |       );
197 |       expect(languageModelErrors).toHaveLength(0);
198 |     });
199 | 
200 |     it('should accept dual language model connection for fallback', () => {
201 |       const agent: WorkflowNode = {
202 |         id: 'agent1',
203 |         name: 'AI Agent',
204 |         type: '@n8n/n8n-nodes-langchain.agent',
205 |         position: [0, 0],
206 |         parameters: { promptType: 'auto' },
207 |         typeVersion: 1.7
208 |       };
209 | 
210 |       const workflow: WorkflowJson = {
211 |         nodes: [agent],
212 |         connections: {
213 |           'OpenAI GPT-4': {
214 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
215 |           },
216 |           'OpenAI GPT-3.5': {
217 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
218 |           }
219 |         }
220 |       };
221 | 
222 |       const reverseMap = buildReverseConnectionMap(workflow);
223 |       const issues = validateAIAgent(agent, reverseMap, workflow);
224 | 
225 |       const excessModelErrors = issues.filter(i =>
226 |         i.severity === 'error' && i.message.includes('more than 2')
227 |       );
228 |       expect(excessModelErrors).toHaveLength(0);
229 |     });
230 | 
231 |     it('should error on more than 2 language model connections', () => {
232 |       const agent: WorkflowNode = {
233 |         id: 'agent1',
234 |         name: 'AI Agent',
235 |         type: '@n8n/n8n-nodes-langchain.agent',
236 |         position: [0, 0],
237 |         parameters: {}
238 |       };
239 | 
240 |       const workflow: WorkflowJson = {
241 |         nodes: [agent],
242 |         connections: {
243 |           'Model1': {
244 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
245 |           },
246 |           'Model2': {
247 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
248 |           },
249 |           'Model3': {
250 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 2 }]]
251 |           }
252 |         }
253 |       };
254 | 
255 |       const reverseMap = buildReverseConnectionMap(workflow);
256 |       const issues = validateAIAgent(agent, reverseMap, workflow);
257 | 
258 |       expect(issues).toContainEqual(
259 |         expect.objectContaining({
260 |           severity: 'error',
261 |           code: 'TOO_MANY_LANGUAGE_MODELS'
262 |         })
263 |       );
264 |     });
265 | 
266 |     it('should error on streaming mode with main output connections', () => {
267 |       const agent: WorkflowNode = {
268 |         id: 'agent1',
269 |         name: 'AI Agent',
270 |         type: '@n8n/n8n-nodes-langchain.agent',
271 |         position: [0, 0],
272 |         parameters: {
273 |           promptType: 'auto',
274 |           options: { streamResponse: true }
275 |         }
276 |       };
277 | 
278 |       const responseNode: WorkflowNode = {
279 |         id: 'response1',
280 |         name: 'Response Node',
281 |         type: 'n8n-nodes-base.respondToWebhook',
282 |         position: [200, 0],
283 |         parameters: {}
284 |       };
285 | 
286 |       const workflow: WorkflowJson = {
287 |         nodes: [agent, responseNode],
288 |         connections: {
289 |           'OpenAI': {
290 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
291 |           },
292 |           'AI Agent': {
293 |             'main': [[{ node: 'Response Node', type: 'main', index: 0 }]]
294 |           }
295 |         }
296 |       };
297 | 
298 |       const reverseMap = buildReverseConnectionMap(workflow);
299 |       const issues = validateAIAgent(agent, reverseMap, workflow);
300 | 
301 |       expect(issues).toContainEqual(
302 |         expect.objectContaining({
303 |           severity: 'error',
304 |           code: 'STREAMING_WITH_MAIN_OUTPUT'
305 |         })
306 |       );
307 |     });
308 | 
309 |     it('should error on missing prompt text for define promptType', () => {
310 |       const agent: WorkflowNode = {
311 |         id: 'agent1',
312 |         name: 'AI Agent',
313 |         type: '@n8n/n8n-nodes-langchain.agent',
314 |         position: [0, 0],
315 |         parameters: {
316 |           promptType: 'define'
317 |         }
318 |       };
319 | 
320 |       const workflow: WorkflowJson = {
321 |         nodes: [agent],
322 |         connections: {
323 |           'OpenAI': {
324 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
325 |           }
326 |         }
327 |       };
328 | 
329 |       const reverseMap = buildReverseConnectionMap(workflow);
330 |       const issues = validateAIAgent(agent, reverseMap, workflow);
331 | 
332 |       expect(issues).toContainEqual(
333 |         expect.objectContaining({
334 |           severity: 'error',
335 |           code: 'MISSING_PROMPT_TEXT'
336 |         })
337 |       );
338 |     });
339 | 
340 |     it('should info on short systemMessage', () => {
341 |       const agent: WorkflowNode = {
342 |         id: 'agent1',
343 |         name: 'AI Agent',
344 |         type: '@n8n/n8n-nodes-langchain.agent',
345 |         position: [0, 0],
346 |         parameters: {
347 |           promptType: 'auto',
348 |           systemMessage: 'Help user'  // Too short (< 20 chars)
349 |         }
350 |       };
351 | 
352 |       const workflow: WorkflowJson = {
353 |         nodes: [agent],
354 |         connections: {
355 |           'OpenAI': {
356 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
357 |           }
358 |         }
359 |       };
360 | 
361 |       const reverseMap = buildReverseConnectionMap(workflow);
362 |       const issues = validateAIAgent(agent, reverseMap, workflow);
363 | 
364 |       expect(issues).toContainEqual(
365 |         expect.objectContaining({
366 |           severity: 'info',
367 |           message: expect.stringContaining('systemMessage is very short')
368 |         })
369 |       );
370 |     });
371 | 
372 |     it('should error on multiple memory connections', () => {
373 |       const agent: WorkflowNode = {
374 |         id: 'agent1',
375 |         name: 'AI Agent',
376 |         type: '@n8n/n8n-nodes-langchain.agent',
377 |         position: [0, 0],
378 |         parameters: { promptType: 'auto' }
379 |       };
380 | 
381 |       const workflow: WorkflowJson = {
382 |         nodes: [agent],
383 |         connections: {
384 |           'OpenAI': {
385 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
386 |           },
387 |           'Memory1': {
388 |             'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
389 |           },
390 |           'Memory2': {
391 |             'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 1 }]]
392 |           }
393 |         }
394 |       };
395 | 
396 |       const reverseMap = buildReverseConnectionMap(workflow);
397 |       const issues = validateAIAgent(agent, reverseMap, workflow);
398 | 
399 |       expect(issues).toContainEqual(
400 |         expect.objectContaining({
401 |           severity: 'error',
402 |           code: 'MULTIPLE_MEMORY_CONNECTIONS'
403 |         })
404 |       );
405 |     });
406 | 
407 |     it('should warn on high maxIterations', () => {
408 |       const agent: WorkflowNode = {
409 |         id: 'agent1',
410 |         name: 'AI Agent',
411 |         type: '@n8n/n8n-nodes-langchain.agent',
412 |         position: [0, 0],
413 |         parameters: {
414 |           promptType: 'auto',
415 |           maxIterations: 60  // Exceeds threshold of 50
416 |         }
417 |       };
418 | 
419 |       const workflow: WorkflowJson = {
420 |         nodes: [agent],
421 |         connections: {
422 |           'OpenAI': {
423 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
424 |           }
425 |         }
426 |       };
427 | 
428 |       const reverseMap = buildReverseConnectionMap(workflow);
429 |       const issues = validateAIAgent(agent, reverseMap, workflow);
430 | 
431 |       expect(issues).toContainEqual(
432 |         expect.objectContaining({
433 |           severity: 'warning',
434 |           message: expect.stringContaining('maxIterations')
435 |         })
436 |       );
437 |     });
438 | 
439 |     it('should validate output parser with hasOutputParser flag', () => {
440 |       const agent: WorkflowNode = {
441 |         id: 'agent1',
442 |         name: 'AI Agent',
443 |         type: '@n8n/n8n-nodes-langchain.agent',
444 |         position: [0, 0],
445 |         parameters: {
446 |           promptType: 'auto',
447 |           hasOutputParser: true
448 |         }
449 |       };
450 | 
451 |       const workflow: WorkflowJson = {
452 |         nodes: [agent],
453 |         connections: {
454 |           'OpenAI': {
455 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
456 |           }
457 |         }
458 |       };
459 | 
460 |       const reverseMap = buildReverseConnectionMap(workflow);
461 |       const issues = validateAIAgent(agent, reverseMap, workflow);
462 | 
463 |       expect(issues).toContainEqual(
464 |         expect.objectContaining({
465 |           severity: 'error',
466 |           message: expect.stringContaining('output parser')
467 |         })
468 |       );
469 |     });
470 |   });
471 | 
472 |   describe('validateChatTrigger', () => {
473 |     it('should error on streaming mode to non-AI-Agent target', () => {
474 |       const trigger: WorkflowNode = {
475 |         id: 'chat1',
476 |         name: 'Chat Trigger',
477 |         type: '@n8n/n8n-nodes-langchain.chatTrigger',
478 |         position: [0, 0],
479 |         parameters: {
480 |           options: { responseMode: 'streaming' }
481 |         }
482 |       };
483 | 
484 |       const codeNode: WorkflowNode = {
485 |         id: 'code1',
486 |         name: 'Code',
487 |         type: 'n8n-nodes-base.code',
488 |         position: [200, 0],
489 |         parameters: {}
490 |       };
491 | 
492 |       const workflow: WorkflowJson = {
493 |         nodes: [trigger, codeNode],
494 |         connections: {
495 |           'Chat Trigger': {
496 |             'main': [[{ node: 'Code', type: 'main', index: 0 }]]
497 |           }
498 |         }
499 |       };
500 | 
501 |       const reverseMap = buildReverseConnectionMap(workflow);
502 |       const issues = validateChatTrigger(trigger, workflow, reverseMap);
503 | 
504 |       expect(issues).toContainEqual(
505 |         expect.objectContaining({
506 |           severity: 'error',
507 |           code: 'STREAMING_WRONG_TARGET'
508 |         })
509 |       );
510 |     });
511 | 
512 |     it('should pass valid Chat Trigger with streaming to AI Agent', () => {
513 |       const trigger: WorkflowNode = {
514 |         id: 'chat1',
515 |         name: 'Chat Trigger',
516 |         type: '@n8n/n8n-nodes-langchain.chatTrigger',
517 |         position: [0, 0],
518 |         parameters: {
519 |           options: { responseMode: 'streaming' }
520 |         }
521 |       };
522 | 
523 |       const agent: WorkflowNode = {
524 |         id: 'agent1',
525 |         name: 'AI Agent',
526 |         type: '@n8n/n8n-nodes-langchain.agent',
527 |         position: [200, 0],
528 |         parameters: {}
529 |       };
530 | 
531 |       const workflow: WorkflowJson = {
532 |         nodes: [trigger, agent],
533 |         connections: {
534 |           'Chat Trigger': {
535 |             'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
536 |           }
537 |         }
538 |       };
539 | 
540 |       const reverseMap = buildReverseConnectionMap(workflow);
541 |       const issues = validateChatTrigger(trigger, workflow, reverseMap);
542 | 
543 |       const errors = issues.filter(i => i.severity === 'error');
544 |       expect(errors).toHaveLength(0);
545 |     });
546 | 
547 |     it('should error on missing outgoing connections', () => {
548 |       const trigger: WorkflowNode = {
549 |         id: 'chat1',
550 |         name: 'Chat Trigger',
551 |         type: '@n8n/n8n-nodes-langchain.chatTrigger',
552 |         position: [0, 0],
553 |         parameters: {}
554 |       };
555 | 
556 |       const workflow: WorkflowJson = {
557 |         nodes: [trigger],
558 |         connections: {}
559 |       };
560 | 
561 |       const reverseMap = buildReverseConnectionMap(workflow);
562 |       const issues = validateChatTrigger(trigger, workflow, reverseMap);
563 | 
564 |       expect(issues).toContainEqual(
565 |         expect.objectContaining({
566 |           severity: 'error',
567 |           code: 'MISSING_CONNECTIONS'
568 |         })
569 |       );
570 |     });
571 |   });
572 | 
573 |   describe('validateBasicLLMChain', () => {
574 |     it('should error on missing language model connection', () => {
575 |       const chain: WorkflowNode = {
576 |         id: 'chain1',
577 |         name: 'LLM Chain',
578 |         type: '@n8n/n8n-nodes-langchain.chainLlm',
579 |         position: [0, 0],
580 |         parameters: {}
581 |       };
582 | 
583 |       const workflow: WorkflowJson = {
584 |         nodes: [chain],
585 |         connections: {}
586 |       };
587 | 
588 |       const reverseMap = buildReverseConnectionMap(workflow);
589 |       const issues = validateBasicLLMChain(chain, reverseMap);
590 | 
591 |       expect(issues).toContainEqual(
592 |         expect.objectContaining({
593 |           severity: 'error',
594 |           message: expect.stringContaining('language model')
595 |         })
596 |       );
597 |     });
598 | 
599 |     it('should pass valid LLM Chain', () => {
600 |       const chain: WorkflowNode = {
601 |         id: 'chain1',
602 |         name: 'LLM Chain',
603 |         type: '@n8n/n8n-nodes-langchain.chainLlm',
604 |         position: [0, 0],
605 |         parameters: {
606 |           prompt: 'Summarize the following text: {{$json.text}}'
607 |         }
608 |       };
609 | 
610 |       const workflow: WorkflowJson = {
611 |         nodes: [chain],
612 |         connections: {
613 |           'OpenAI': {
614 |             'ai_languageModel': [[{ node: 'LLM Chain', type: 'ai_languageModel', index: 0 }]]
615 |           }
616 |         }
617 |       };
618 | 
619 |       const reverseMap = buildReverseConnectionMap(workflow);
620 |       const issues = validateBasicLLMChain(chain, reverseMap);
621 | 
622 |       const errors = issues.filter(i => i.severity === 'error');
623 |       expect(errors).toHaveLength(0);
624 |     });
625 |   });
626 | 
627 |   describe('validateAISpecificNodes', () => {
628 |     it('should validate complete AI Agent workflow', () => {
629 |       const chatTrigger: WorkflowNode = {
630 |         id: 'chat1',
631 |         name: 'Chat Trigger',
632 |         type: '@n8n/n8n-nodes-langchain.chatTrigger',
633 |         position: [0, 0],
634 |         parameters: {}
635 |       };
636 | 
637 |       const agent: WorkflowNode = {
638 |         id: 'agent1',
639 |         name: 'AI Agent',
640 |         type: '@n8n/n8n-nodes-langchain.agent',
641 |         position: [200, 0],
642 |         parameters: {
643 |           promptType: 'auto'
644 |         }
645 |       };
646 | 
647 |       const model: WorkflowNode = {
648 |         id: 'llm1',
649 |         name: 'OpenAI',
650 |         type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
651 |         position: [200, -100],
652 |         parameters: {}
653 |       };
654 | 
655 |       const httpTool: WorkflowNode = {
656 |         id: 'tool1',
657 |         name: 'Weather API',
658 |         type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
659 |         position: [200, 100],
660 |         parameters: {
661 |           toolDescription: 'Get current weather for a city',
662 |           method: 'GET',
663 |           url: 'https://api.weather.com/v1/current?city={city}',
664 |           placeholderDefinitions: {
665 |             values: [
666 |               { name: 'city', description: 'City name' }
667 |             ]
668 |           }
669 |         }
670 |       };
671 | 
672 |       const workflow: WorkflowJson = {
673 |         nodes: [chatTrigger, agent, model, httpTool],
674 |         connections: {
675 |           'Chat Trigger': {
676 |             'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
677 |           },
678 |           'OpenAI': {
679 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
680 |           },
681 |           'Weather API': {
682 |             'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
683 |           }
684 |         }
685 |       };
686 | 
687 |       const issues = validateAISpecificNodes(workflow);
688 | 
689 |       const errors = issues.filter(i => i.severity === 'error');
690 |       expect(errors).toHaveLength(0);
691 |     });
692 | 
693 |     it('should detect missing language model in workflow', () => {
694 |       const agent: WorkflowNode = {
695 |         id: 'agent1',
696 |         name: 'AI Agent',
697 |         type: '@n8n/n8n-nodes-langchain.agent',
698 |         position: [0, 0],
699 |         parameters: {}
700 |       };
701 | 
702 |       const workflow: WorkflowJson = {
703 |         nodes: [agent],
704 |         connections: {}
705 |       };
706 | 
707 |       const issues = validateAISpecificNodes(workflow);
708 | 
709 |       expect(issues).toContainEqual(
710 |         expect.objectContaining({
711 |           severity: 'error',
712 |           message: expect.stringContaining('language model')
713 |         })
714 |       );
715 |     });
716 | 
717 |     it('should validate all AI tool sub-nodes in workflow', () => {
718 |       const agent: WorkflowNode = {
719 |         id: 'agent1',
720 |         name: 'AI Agent',
721 |         type: '@n8n/n8n-nodes-langchain.agent',
722 |         position: [0, 0],
723 |         parameters: { promptType: 'auto' }
724 |       };
725 | 
726 |       const invalidTool: WorkflowNode = {
727 |         id: 'tool1',
728 |         name: 'Bad Tool',
729 |         type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
730 |         position: [0, 100],
731 |         parameters: {}  // Missing toolDescription and url
732 |       };
733 | 
734 |       const workflow: WorkflowJson = {
735 |         nodes: [agent, invalidTool],
736 |         connections: {
737 |           'Model': {
738 |             'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
739 |           },
740 |           'Bad Tool': {
741 |             'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
742 |           }
743 |         }
744 |       };
745 | 
746 |       const issues = validateAISpecificNodes(workflow);
747 | 
748 |       // Should have errors from missing toolDescription and url
749 |       expect(issues.filter(i => i.severity === 'error').length).toBeGreaterThan(0);
750 |     });
751 |   });
752 | });
753 | 
```
Page 30/59FirstPrevNextLast