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 | ```