This is page 22 of 46. Use http://codebase.md/czlonkowski/n8n-mcp?lines=false&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-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/n8n-api/workflows/create-workflow.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Tests: handleCreateWorkflow * * Tests workflow creation against a real n8n instance. * Verifies the P0 bug fix (FULL vs SHORT node type formats) * and covers all major workflow creation scenarios. */ import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context'; import { getTestN8nClient } from '../utils/n8n-client'; import { N8nApiClient } from '../../../../src/services/n8n-api-client'; import { Workflow } from '../../../../src/types/n8n-api'; import { SIMPLE_WEBHOOK_WORKFLOW, SIMPLE_HTTP_WORKFLOW, MULTI_NODE_WORKFLOW, ERROR_HANDLING_WORKFLOW, AI_AGENT_WORKFLOW, EXPRESSION_WORKFLOW, getFixture } from '../utils/fixtures'; import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers'; import { createMcpContext } from '../utils/mcp-context'; import { InstanceContext } from '../../../../src/types/instance-context'; import { handleCreateWorkflow } from '../../../../src/mcp/handlers-n8n-manager'; describe('Integration: handleCreateWorkflow', () => { let context: TestContext; let client: N8nApiClient; let mcpContext: InstanceContext; beforeEach(() => { context = createTestContext(); client = getTestN8nClient(); mcpContext = createMcpContext(); }); afterEach(async () => { await context.cleanup(); }); // Global cleanup after all tests to catch any orphaned workflows // (e.g., from test retries or failures) // IMPORTANT: Skip cleanup in CI to preserve shared n8n instance workflows afterAll(async () => { if (!process.env.CI) { await cleanupOrphanedWorkflows(); } }); // ====================================================================== // P0: Critical Bug Verification // ====================================================================== describe('P0: Node Type Format Bug Fix', () => { it('should create workflow with webhook node using FULL node type format', async () => { // This test verifies the P0 bug fix where SHORT node type format // (e.g., "webhook") was incorrectly normalized to FULL format // causing workflow creation failures. // // The fix ensures FULL format (e.g., "n8n-nodes-base.webhook") // is preserved and passed to n8n API correctly. const workflowName = createTestWorkflowName('P0 Bug Verification - Webhook Node'); const workflow = { name: workflowName, ...getFixture('simple-webhook') }; // Create workflow using MCP handler const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(true); const result = response.data as Workflow; // Verify workflow created successfully expect(result).toBeDefined(); expect(result.id).toBeTruthy(); if (!result.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(result.id); expect(result.name).toBe(workflowName); expect(result.nodes).toHaveLength(1); // Critical: Verify FULL node type format is preserved expect(result.nodes[0].type).toBe('n8n-nodes-base.webhook'); expect(result.nodes[0].name).toBe('Webhook'); expect(result.nodes[0].parameters).toBeDefined(); }); }); // ====================================================================== // P1: Base Nodes (High Priority) // ====================================================================== describe('P1: Base n8n Nodes', () => { it('should create workflow with HTTP Request node', async () => { const workflowName = createTestWorkflowName('HTTP Request Node'); const workflow = { name: workflowName, ...getFixture('simple-http') }; const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(true); const result = response.data as Workflow; expect(result).toBeDefined(); expect(result.id).toBeTruthy(); if (!result.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(result.id); expect(result.name).toBe(workflowName); expect(result.nodes).toHaveLength(2); // Verify both nodes created with FULL type format const webhookNode = result.nodes.find((n: any) => n.name === 'Webhook'); const httpNode = result.nodes.find((n: any) => n.name === 'HTTP Request'); expect(webhookNode).toBeDefined(); expect(webhookNode!.type).toBe('n8n-nodes-base.webhook'); expect(httpNode).toBeDefined(); expect(httpNode!.type).toBe('n8n-nodes-base.httpRequest'); // Verify connections expect(result.connections).toBeDefined(); expect(result.connections.Webhook).toBeDefined(); }); it('should create workflow with langchain agent node', async () => { const workflowName = createTestWorkflowName('Langchain Agent Node'); const workflow = { name: workflowName, ...getFixture('ai-agent') }; const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(true); const result = response.data as Workflow; expect(result).toBeDefined(); expect(result.id).toBeTruthy(); if (!result.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(result.id); expect(result.name).toBe(workflowName); expect(result.nodes).toHaveLength(2); // Verify langchain node type format const agentNode = result.nodes.find((n: any) => n.name === 'AI Agent'); expect(agentNode).toBeDefined(); expect(agentNode!.type).toBe('@n8n/n8n-nodes-langchain.agent'); }); it('should create complex multi-node workflow', async () => { const workflowName = createTestWorkflowName('Multi-Node Workflow'); const workflow = { name: workflowName, ...getFixture('multi-node') }; const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(true); const result = response.data as Workflow; expect(result).toBeDefined(); expect(result.id).toBeTruthy(); if (!result.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(result.id); expect(result.name).toBe(workflowName); expect(result.nodes).toHaveLength(4); // Verify all node types preserved const nodeTypes = result.nodes.map((n: any) => n.type); expect(nodeTypes).toContain('n8n-nodes-base.webhook'); expect(nodeTypes).toContain('n8n-nodes-base.set'); expect(nodeTypes).toContain('n8n-nodes-base.merge'); // Verify complex connections expect(result.connections.Webhook.main[0]).toHaveLength(2); // Branches to 2 nodes }); }); // ====================================================================== // P2: Advanced Features (Medium Priority) // ====================================================================== describe('P2: Advanced Workflow Features', () => { it('should create workflow with complex connections and branching', async () => { const workflowName = createTestWorkflowName('Complex Connections'); const workflow = { name: workflowName, ...getFixture('multi-node') }; const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(true); const result = response.data as Workflow; expect(result).toBeDefined(); expect(result.id).toBeTruthy(); if (!result.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(result.id); expect(result.connections).toBeDefined(); // Verify branching: Webhook -> Set 1 and Set 2 const webhookConnections = result.connections.Webhook.main[0]; expect(webhookConnections).toHaveLength(2); // Verify merging: Set 1 -> Merge (port 0), Set 2 -> Merge (port 1) const set1Connections = result.connections['Set 1'].main[0]; const set2Connections = result.connections['Set 2'].main[0]; expect(set1Connections[0].node).toBe('Merge'); expect(set1Connections[0].index).toBe(0); expect(set2Connections[0].node).toBe('Merge'); expect(set2Connections[0].index).toBe(1); }); it('should create workflow with custom settings', async () => { const workflowName = createTestWorkflowName('Custom Settings'); const workflow = { name: workflowName, ...getFixture('error-handling'), settings: { executionOrder: 'v1' as const, timezone: 'America/New_York', saveDataErrorExecution: 'all' as const, saveDataSuccessExecution: 'all' as const, saveExecutionProgress: true } }; const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(true); const result = response.data as Workflow; expect(result).toBeDefined(); expect(result.id).toBeTruthy(); if (!result.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(result.id); expect(result.settings).toBeDefined(); expect(result.settings!.executionOrder).toBe('v1'); }); it('should create workflow with n8n expressions', async () => { const workflowName = createTestWorkflowName('n8n Expressions'); const workflow = { name: workflowName, ...getFixture('expression') }; const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(true); const result = response.data as Workflow; expect(result).toBeDefined(); expect(result.id).toBeTruthy(); if (!result.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(result.id); expect(result.nodes).toHaveLength(2); // Verify Set node with expressions const setNode = result.nodes.find((n: any) => n.name === 'Set Variables'); expect(setNode).toBeDefined(); expect(setNode!.parameters.assignments).toBeDefined(); // Verify expressions are preserved const assignmentsData = setNode!.parameters.assignments as { assignments: Array<{ value: string }> }; expect(assignmentsData.assignments).toHaveLength(3); expect(assignmentsData.assignments[0].value).toContain('$now'); expect(assignmentsData.assignments[1].value).toContain('$json'); expect(assignmentsData.assignments[2].value).toContain('$node'); }); it('should create workflow with error handling configuration', async () => { const workflowName = createTestWorkflowName('Error Handling'); const workflow = { name: workflowName, ...getFixture('error-handling') }; const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(true); const result = response.data as Workflow; expect(result).toBeDefined(); expect(result.id).toBeTruthy(); if (!result.id) throw new Error('Workflow ID is missing'); context.trackWorkflow(result.id); expect(result.nodes).toHaveLength(3); // Verify HTTP node with error handling const httpNode = result.nodes.find((n: any) => n.name === 'HTTP Request'); expect(httpNode).toBeDefined(); expect(httpNode!.continueOnFail).toBe(true); expect(httpNode!.onError).toBe('continueErrorOutput'); // Verify error connection expect(result.connections['HTTP Request'].error).toBeDefined(); expect(result.connections['HTTP Request'].error[0][0].node).toBe('Handle Error'); }); }); // ====================================================================== // Error Scenarios (P1 Priority) // ====================================================================== describe('Error Scenarios', () => { it('should reject workflow with invalid node type (MCP validation)', async () => { // MCP handler correctly validates workflows before sending to n8n API. // Invalid node types are caught during MCP validation. // // Note: Raw n8n API would accept this and only fail at execution time, // but MCP handler does proper pre-validation (correct behavior). const workflowName = createTestWorkflowName('Invalid Node Type'); const workflow = { name: workflowName, nodes: [ { id: 'invalid-1', name: 'Invalid Node', type: 'n8n-nodes-base.nonexistentnode', typeVersion: 1, position: [250, 300] as [number, number], parameters: {} } ], connections: {}, settings: { executionOrder: 'v1' as const } }; // MCP handler rejects invalid workflows (correct behavior) const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(false); expect(response.error).toBeDefined(); expect(response.error).toContain('validation'); }); it('should reject workflow with missing required node parameters (MCP validation)', async () => { // MCP handler validates required parameters before sending to n8n API. // // Note: Raw n8n API would accept this and only fail at execution time, // but MCP handler does proper pre-validation (correct behavior). const workflowName = createTestWorkflowName('Missing Parameters'); const workflow = { name: workflowName, nodes: [ { id: 'http-1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4.2, position: [250, 300] as [number, number], parameters: { // Missing required 'url' parameter method: 'GET' } } ], connections: {}, settings: { executionOrder: 'v1' as const } }; // MCP handler rejects workflows with validation errors (correct behavior) const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(false); expect(response.error).toBeDefined(); }); it('should reject workflow with duplicate node names (MCP validation)', async () => { // MCP handler validates that node names are unique. // // Note: Raw n8n API might auto-rename duplicates, but MCP handler // enforces unique names upfront (correct behavior). const workflowName = createTestWorkflowName('Duplicate Node Names'); const workflow = { name: workflowName, nodes: [ { id: 'set-1', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [250, 300] as [number, number], parameters: { assignments: { assignments: [] }, options: {} } }, { id: 'set-2', name: 'Set', // Duplicate name type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [450, 300] as [number, number], parameters: { assignments: { assignments: [] }, options: {} } } ], connections: {}, settings: { executionOrder: 'v1' as const } }; // MCP handler rejects workflows with validation errors (correct behavior) const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(false); expect(response.error).toBeDefined(); }); it('should reject workflow with invalid connection references (MCP validation)', async () => { // MCP handler validates that connection references point to existing nodes. // // Note: Raw n8n API would accept this and only fail at execution time, // but MCP handler does proper connection validation (correct behavior). const workflowName = createTestWorkflowName('Invalid Connections'); const workflow = { name: workflowName, nodes: [ { id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300] as [number, number], parameters: { httpMethod: 'GET', path: 'test' } } ], connections: { // Connection references non-existent node Webhook: { main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] } }, settings: { executionOrder: 'v1' as const } }; // MCP handler rejects workflows with invalid connections (correct behavior) const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(false); expect(response.error).toBeDefined(); expect(response.error).toContain('validation'); }); }); // ====================================================================== // Additional Edge Cases // ====================================================================== describe('Edge Cases', () => { it('should reject single-node non-webhook workflow (MCP validation)', async () => { // MCP handler enforces that single-node workflows are only valid for webhooks. // This is a best practice validation. const workflowName = createTestWorkflowName('Minimal Single Node'); const workflow = { name: workflowName, nodes: [ { id: 'manual-1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [250, 300] as [number, number], parameters: {} } ], connections: {}, settings: { executionOrder: 'v1' as const } }; // MCP handler rejects single-node non-webhook workflows (correct behavior) const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(false); expect(response.error).toBeDefined(); expect(response.error).toContain('validation'); }); it('should reject single-node non-trigger workflow (MCP validation)', async () => { // MCP handler enforces workflow best practices. // Single isolated nodes without connections are rejected. const workflowName = createTestWorkflowName('Empty Connections'); const workflow = { name: workflowName, nodes: [ { id: 'set-1', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [250, 300] as [number, number], parameters: { assignments: { assignments: [] }, options: {} } } ], connections: {}, // Explicitly empty settings: { executionOrder: 'v1' as const } }; // MCP handler rejects single-node workflows (correct behavior) const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(false); expect(response.error).toBeDefined(); }); it('should reject single-node workflow without settings (MCP validation)', async () => { // MCP handler enforces workflow best practices. // Single-node non-webhook workflows are rejected. const workflowName = createTestWorkflowName('No Settings'); const workflow = { name: workflowName, nodes: [ { id: 'manual-1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [250, 300] as [number, number], parameters: {} } ], connections: {} // No settings property }; // MCP handler rejects single-node workflows (correct behavior) const response = await handleCreateWorkflow({ ...workflow }, mcpContext); expect(response.success).toBe(false); expect(response.error).toBeDefined(); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/tool-invocation.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { TestableN8NMCPServer } from './test-helpers'; describe('MCP Tool Invocation', () => { let mcpServer: TestableN8NMCPServer; let client: Client; beforeEach(async () => { mcpServer = new TestableN8NMCPServer(); await mcpServer.initialize(); const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); await mcpServer.connectToTransport(serverTransport); client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); await client.connect(clientTransport); }); afterEach(async () => { await client.close(); await mcpServer.close(); }); describe('Node Discovery Tools', () => { describe('list_nodes', () => { it('should list nodes with default parameters', async () => { const response = await client.callTool({ name: 'list_nodes', arguments: {} }); expect((response as any).content).toHaveLength(1); expect((response as any).content[0].type).toBe('text'); const result = JSON.parse(((response as any).content[0]).text); // The result is an object with nodes array and totalCount expect(result).toHaveProperty('nodes'); expect(result).toHaveProperty('totalCount'); const nodes = result.nodes; expect(Array.isArray(nodes)).toBe(true); expect(nodes.length).toBeGreaterThan(0); // Check node structure const firstNode = nodes[0]; expect(firstNode).toHaveProperty('nodeType'); expect(firstNode).toHaveProperty('displayName'); expect(firstNode).toHaveProperty('category'); }); it('should filter nodes by category', async () => { const response = await client.callTool({ name: 'list_nodes', arguments: { category: 'trigger' }}); const result = JSON.parse(((response as any).content[0]).text); const nodes = result.nodes; expect(nodes.length).toBeGreaterThan(0); nodes.forEach((node: any) => { expect(node.category).toBe('trigger'); }); }); it('should limit results', async () => { const response = await client.callTool({ name: 'list_nodes', arguments: { limit: 5 }}); const result = JSON.parse(((response as any).content[0]).text); const nodes = result.nodes; expect(nodes).toHaveLength(5); }); it('should filter by package', async () => { const response = await client.callTool({ name: 'list_nodes', arguments: { package: 'n8n-nodes-base' }}); const result = JSON.parse(((response as any).content[0]).text); const nodes = result.nodes; expect(nodes.length).toBeGreaterThan(0); nodes.forEach((node: any) => { expect(node.package).toBe('n8n-nodes-base'); }); }); }); describe('search_nodes', () => { it('should search nodes by keyword', async () => { const response = await client.callTool({ name: 'search_nodes', arguments: { query: 'webhook' }}); const result = JSON.parse(((response as any).content[0]).text); const nodes = result.results; expect(nodes.length).toBeGreaterThan(0); // Should find webhook node const webhookNode = nodes.find((n: any) => n.displayName.toLowerCase().includes('webhook')); expect(webhookNode).toBeDefined(); }); it('should support different search modes', async () => { // OR mode const orResponse = await client.callTool({ name: 'search_nodes', arguments: { query: 'http request', mode: 'OR' }}); const orResult = JSON.parse(((orResponse as any).content[0]).text); const orNodes = orResult.results; expect(orNodes.length).toBeGreaterThan(0); // AND mode const andResponse = await client.callTool({ name: 'search_nodes', arguments: { query: 'http request', mode: 'AND' }}); const andResult = JSON.parse(((andResponse as any).content[0]).text); const andNodes = andResult.results; expect(andNodes.length).toBeLessThanOrEqual(orNodes.length); // FUZZY mode - use less typo-heavy search const fuzzyResponse = await client.callTool({ name: 'search_nodes', arguments: { query: 'http req', // Partial match should work mode: 'FUZZY' }}); const fuzzyResult = JSON.parse(((fuzzyResponse as any).content[0]).text); const fuzzyNodes = fuzzyResult.results; expect(fuzzyNodes.length).toBeGreaterThan(0); }); it('should respect result limit', async () => { const response = await client.callTool({ name: 'search_nodes', arguments: { query: 'node', limit: 3 }}); const result = JSON.parse(((response as any).content[0]).text); const nodes = result.results; expect(nodes).toHaveLength(3); }); }); describe('get_node_info', () => { it('should get complete node information', async () => { const response = await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'nodes-base.httpRequest' }}); expect(((response as any).content[0]).type).toBe('text'); const nodeInfo = JSON.parse(((response as any).content[0]).text); expect(nodeInfo).toHaveProperty('nodeType', 'nodes-base.httpRequest'); expect(nodeInfo).toHaveProperty('displayName'); expect(nodeInfo).toHaveProperty('properties'); expect(Array.isArray(nodeInfo.properties)).toBe(true); }); it('should handle non-existent nodes', async () => { try { await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'nodes-base.nonExistent' }}); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error.message).toContain('not found'); } }); it('should handle invalid node type format', async () => { try { await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalidFormat' }}); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error.message).toContain('not found'); } }); }); describe('get_node_essentials', () => { it('should return condensed node information', async () => { const response = await client.callTool({ name: 'get_node_essentials', arguments: { nodeType: 'nodes-base.httpRequest' }}); const essentials = JSON.parse(((response as any).content[0]).text); expect(essentials).toHaveProperty('nodeType'); expect(essentials).toHaveProperty('displayName'); expect(essentials).toHaveProperty('commonProperties'); expect(essentials).toHaveProperty('requiredProperties'); // Should be smaller than full info const fullResponse = await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'nodes-base.httpRequest' }}); expect(((response as any).content[0]).text.length).toBeLessThan(((fullResponse as any).content[0]).text.length); }); }); }); describe('Validation Tools', () => { describe('validate_node_operation', () => { it('should validate valid node configuration', async () => { const response = await client.callTool({ name: 'validate_node_operation', arguments: { nodeType: 'nodes-base.httpRequest', config: { method: 'GET', url: 'https://api.example.com/data' } }}); const validation = JSON.parse(((response as any).content[0]).text); expect(validation).toHaveProperty('valid'); expect(validation).toHaveProperty('errors'); expect(validation).toHaveProperty('warnings'); }); it('should detect missing required fields', async () => { const response = await client.callTool({ name: 'validate_node_operation', arguments: { nodeType: 'nodes-base.httpRequest', config: { method: 'GET' // Missing required 'url' field } }}); const validation = JSON.parse(((response as any).content[0]).text); expect(validation.valid).toBe(false); expect(validation.errors.length).toBeGreaterThan(0); expect(validation.errors[0].message.toLowerCase()).toContain('url'); }); it('should support different validation profiles', async () => { const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict']; for (const profile of profiles) { const response = await client.callTool({ name: 'validate_node_operation', arguments: { nodeType: 'nodes-base.httpRequest', config: { method: 'GET', url: 'https://api.example.com' }, profile }}); const validation = JSON.parse(((response as any).content[0]).text); expect(validation).toHaveProperty('profile', profile); } }); }); describe('validate_workflow', () => { it('should validate complete workflow', async () => { const workflow = { nodes: [ { id: '1', name: 'Start', type: 'nodes-base.manualTrigger', typeVersion: 1, position: [0, 0], parameters: {} }, { id: '2', name: 'HTTP Request', type: 'nodes-base.httpRequest', typeVersion: 3, position: [250, 0], parameters: { method: 'GET', url: 'https://api.example.com/data' } } ], connections: { 'Start': { 'main': [[{ node: 'HTTP Request', type: 'main', index: 0 }]] } } }; const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow }}); const validation = JSON.parse(((response as any).content[0]).text); expect(validation).toHaveProperty('valid'); expect(validation).toHaveProperty('errors'); expect(validation).toHaveProperty('warnings'); }); it('should detect connection errors', async () => { const workflow = { nodes: [ { id: '1', name: 'Start', type: 'nodes-base.manualTrigger', typeVersion: 1, position: [0, 0], parameters: {} } ], connections: { 'Start': { 'main': [[{ node: 'NonExistent', type: 'main', index: 0 }]] } } }; const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow }}); const validation = JSON.parse(((response as any).content[0]).text); expect(validation.valid).toBe(false); expect(validation.errors.length).toBeGreaterThan(0); }); it('should validate expressions', async () => { const workflow = { nodes: [ { id: '1', name: 'Start', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [0, 0], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [250, 0], parameters: { mode: 'manual', duplicateItem: false, values: { string: [ { name: 'test', value: '={{ $json.invalidExpression }}' } ] } } } ], connections: { 'Start': { 'main': [[{ node: 'Set', type: 'main', index: 0 }]] } } }; const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow, options: { validateExpressions: true } }}); const validation = JSON.parse(((response as any).content[0]).text); expect(validation).toHaveProperty('valid'); // The workflow should have either errors or warnings about the expression if (validation.errors && validation.errors.length > 0) { expect(validation.errors.some((e: any) => e.message.includes('expression') || e.message.includes('$json') )).toBe(true); } else if (validation.warnings) { expect(validation.warnings.length).toBeGreaterThan(0); expect(validation.warnings.some((w: any) => w.message.includes('expression') || w.message.includes('$json') )).toBe(true); } }); }); }); describe('Documentation Tools', () => { describe('tools_documentation', () => { it('should get quick start guide', async () => { const response = await client.callTool({ name: 'tools_documentation', arguments: {} }); expect(((response as any).content[0]).type).toBe('text'); expect(((response as any).content[0]).text).toContain('n8n MCP Tools'); }); it('should get specific tool documentation', async () => { const response = await client.callTool({ name: 'tools_documentation', arguments: { topic: 'search_nodes' }}); expect(((response as any).content[0]).text).toContain('search_nodes'); expect(((response as any).content[0]).text).toContain('Text search'); }); it('should get comprehensive documentation', async () => { const response = await client.callTool({ name: 'tools_documentation', arguments: { depth: 'full' }}); expect(((response as any).content[0]).text.length).toBeGreaterThan(5000); expect(((response as any).content[0]).text).toBeDefined(); }); it('should handle invalid topics gracefully', async () => { const response = await client.callTool({ name: 'tools_documentation', arguments: { topic: 'nonexistent_tool' }}); expect(((response as any).content[0]).text).toContain('not found'); }); }); }); describe('AI Tools', () => { describe('list_ai_tools', () => { it('should list AI-capable nodes', async () => { const response = await client.callTool({ name: 'list_ai_tools', arguments: {} }); const result = JSON.parse(((response as any).content[0]).text); expect(result).toHaveProperty('tools'); const aiTools = result.tools; expect(Array.isArray(aiTools)).toBe(true); expect(aiTools.length).toBeGreaterThan(0); // All should have nodeType and displayName aiTools.forEach((tool: any) => { expect(tool).toHaveProperty('nodeType'); expect(tool).toHaveProperty('displayName'); }); }); }); describe('get_node_as_tool_info', () => { it('should provide AI tool usage information', async () => { const response = await client.callTool({ name: 'get_node_as_tool_info', arguments: { nodeType: 'nodes-base.slack' }}); const info = JSON.parse(((response as any).content[0]).text); expect(info).toHaveProperty('nodeType'); expect(info).toHaveProperty('isMarkedAsAITool'); expect(info).toHaveProperty('aiToolCapabilities'); expect(info.aiToolCapabilities).toHaveProperty('commonUseCases'); }); }); }); describe('Task Templates', () => { // get_node_for_task was removed in v2.15.0 // Use search_nodes({ includeExamples: true }) instead for real-world examples describe('list_tasks', () => { it('should list all available tasks', async () => { const response = await client.callTool({ name: 'list_tasks', arguments: {} }); const result = JSON.parse(((response as any).content[0]).text); expect(result).toHaveProperty('totalTasks'); expect(result).toHaveProperty('categories'); expect(result.totalTasks).toBeGreaterThan(0); // Check categories structure const categories = result.categories; expect(typeof categories).toBe('object'); // Check at least one category has tasks const hasTasksInCategories = Object.values(categories).some((tasks: any) => Array.isArray(tasks) && tasks.length > 0 ); expect(hasTasksInCategories).toBe(true); }); it('should filter by category', async () => { const response = await client.callTool({ name: 'list_tasks', arguments: { category: 'HTTP/API' }}); const result = JSON.parse(((response as any).content[0]).text); expect(result).toHaveProperty('category', 'HTTP/API'); expect(result).toHaveProperty('tasks'); const httpTasks = result.tasks; expect(Array.isArray(httpTasks)).toBe(true); expect(httpTasks.length).toBeGreaterThan(0); httpTasks.forEach((task: any) => { expect(task).toHaveProperty('task'); expect(task).toHaveProperty('description'); expect(task).toHaveProperty('nodeType'); }); }); }); }); describe('Complex Tool Interactions', () => { it('should handle tool chaining', async () => { // Search for nodes const searchResponse = await client.callTool({ name: 'search_nodes', arguments: { query: 'slack' }}); const searchResult = JSON.parse(((searchResponse as any).content[0]).text); const nodes = searchResult.results; // Get info for first result const firstNode = nodes[0]; const infoResponse = await client.callTool({ name: 'get_node_info', arguments: { nodeType: firstNode.nodeType }}); expect(((infoResponse as any).content[0]).text).toContain(firstNode.displayName); }); it('should handle parallel tool calls', async () => { const tools = [ 'list_nodes', 'get_database_statistics', 'list_ai_tools', 'list_tasks' ]; const promises = tools.map(tool => client.callTool({ name: tool as any, arguments: {} }) ); const responses = await Promise.all(promises); expect(responses).toHaveLength(tools.length); responses.forEach(response => { expect(response.content).toHaveLength(1); expect(((response as any).content[0]).type).toBe('text'); }); }); it('should maintain consistency across related tools', async () => { // Get node via different methods const nodeType = 'nodes-base.httpRequest'; const [fullInfo, essentials, searchResult] = await Promise.all([ client.callTool({ name: 'get_node_info', arguments: { nodeType } }), client.callTool({ name: 'get_node_essentials', arguments: { nodeType } }), client.callTool({ name: 'search_nodes', arguments: { query: 'httpRequest' } }) ]); const full = JSON.parse(((fullInfo as any).content[0]).text); const essential = JSON.parse(((essentials as any).content[0]).text); const searchData = JSON.parse(((searchResult as any).content[0]).text); const search = searchData.results; // Should all reference the same node expect(full.nodeType).toBe('nodes-base.httpRequest'); expect(essential.displayName).toBe(full.displayName); expect(search.find((n: any) => n.nodeType === 'nodes-base.httpRequest')).toBeDefined(); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/database/performance.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { NodeRepository } from '../../../src/database/node-repository'; import { TemplateRepository } from '../../../src/templates/template-repository'; import { DatabaseAdapter } from '../../../src/database/database-adapter'; import { TestDatabase, TestDataGenerator, PerformanceMonitor, createTestDatabaseAdapter } from './test-utils'; import { ParsedNode } from '../../../src/parsers/node-parser'; import { TemplateWorkflow, TemplateDetail } from '../../../src/templates/template-fetcher'; describe('Database Performance Tests', () => { let testDb: TestDatabase; let db: Database.Database; let nodeRepo: NodeRepository; let templateRepo: TemplateRepository; let adapter: DatabaseAdapter; let monitor: PerformanceMonitor; beforeEach(async () => { testDb = new TestDatabase({ mode: 'file', name: 'performance-test.db', enableFTS5: true }); db = await testDb.initialize(); adapter = createTestDatabaseAdapter(db); nodeRepo = new NodeRepository(adapter); templateRepo = new TemplateRepository(adapter); monitor = new PerformanceMonitor(); }); afterEach(async () => { monitor.clear(); await testDb.cleanup(); }); describe('Node Repository Performance', () => { it('should handle bulk inserts efficiently', () => { const nodeCounts = [100, 1000, 5000]; nodeCounts.forEach(count => { const nodes = generateNodes(count); const stop = monitor.start(`insert_${count}_nodes`); const transaction = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => nodeRepo.saveNode(node)); }); transaction(nodes); stop(); }); // Check performance metrics const stats100 = monitor.getStats('insert_100_nodes'); const stats1000 = monitor.getStats('insert_1000_nodes'); const stats5000 = monitor.getStats('insert_5000_nodes'); // Environment-aware thresholds const threshold100 = process.env.CI ? 200 : 100; const threshold1000 = process.env.CI ? 1000 : 500; const threshold5000 = process.env.CI ? 4000 : 2000; expect(stats100!.average).toBeLessThan(threshold100); expect(stats1000!.average).toBeLessThan(threshold1000); expect(stats5000!.average).toBeLessThan(threshold5000); // Performance should scale sub-linearly const ratio1000to100 = stats1000!.average / stats100!.average; const ratio5000to1000 = stats5000!.average / stats1000!.average; // Adjusted based on actual CI performance measurements + type safety overhead // CI environments show ratios of ~7-10 for 1000:100 and ~6-7 for 5000:1000 expect(ratio1000to100).toBeLessThan(12); // Allow for CI variability (was 10) expect(ratio5000to1000).toBeLessThan(11); // Allow for type safety overhead (was 8) }); it('should search nodes quickly with indexes', () => { // Insert test data with search-friendly content const searchableNodes = generateSearchableNodes(10000); const transaction = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => nodeRepo.saveNode(node)); }); transaction(searchableNodes); // Test different search scenarios const searchTests = [ { query: 'webhook', mode: 'OR' as const }, { query: 'http request', mode: 'AND' as const }, { query: 'automation data', mode: 'OR' as const }, { query: 'HTT', mode: 'FUZZY' as const } ]; searchTests.forEach(test => { const stop = monitor.start(`search_${test.query}_${test.mode}`); const results = nodeRepo.searchNodes(test.query, test.mode, 100); stop(); expect(results.length).toBeGreaterThan(0); }); // All searches should be fast searchTests.forEach(test => { const stats = monitor.getStats(`search_${test.query}_${test.mode}`); const threshold = process.env.CI ? 100 : 50; expect(stats!.average).toBeLessThan(threshold); }); }); it('should handle concurrent reads efficiently', () => { // Insert initial data const nodes = generateNodes(1000); const transaction = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => nodeRepo.saveNode(node)); }); transaction(nodes); // Simulate concurrent reads const readOperations = 100; const promises: Promise<any>[] = []; const stop = monitor.start('concurrent_reads'); for (let i = 0; i < readOperations; i++) { promises.push( Promise.resolve(nodeRepo.getNode(`n8n-nodes-base.node${i % 1000}`)) ); } Promise.all(promises); stop(); const stats = monitor.getStats('concurrent_reads'); const threshold = process.env.CI ? 200 : 100; expect(stats!.average).toBeLessThan(threshold); // Average per read should be very low const avgPerRead = stats!.average / readOperations; const perReadThreshold = process.env.CI ? 2 : 1; expect(avgPerRead).toBeLessThan(perReadThreshold); }); }); describe('Template Repository Performance with FTS5', () => { it('should perform FTS5 searches efficiently', () => { // Insert templates with varied content const templates = Array.from({ length: 10000 }, (_, i) => { const workflow: TemplateWorkflow = { id: i + 1, name: `${['Webhook', 'HTTP', 'Automation', 'Data Processing'][i % 4]} Workflow ${i}`, description: generateDescription(i), totalViews: Math.floor(Math.random() * 1000), createdAt: new Date().toISOString(), user: { id: 1, name: 'Test User', username: 'user', verified: false }, nodes: [ { id: i * 10 + 1, name: 'Start', icon: 'webhook' } ] }; const detail: TemplateDetail = { id: i + 1, name: workflow.name, description: workflow.description || '', views: workflow.totalViews, createdAt: workflow.createdAt, workflow: { nodes: workflow.nodes, connections: {}, settings: {} } }; return { workflow, detail }; }); const stop1 = monitor.start('insert_templates_with_fts'); const transaction = db.transaction((items: any[]) => { items.forEach(({ workflow, detail }) => { templateRepo.saveTemplate(workflow, detail); }); }); transaction(templates); stop1(); // Ensure FTS index is built db.prepare('INSERT INTO templates_fts(templates_fts) VALUES(\'rebuild\')').run(); // Test various FTS5 searches - use lowercase queries since FTS5 with quotes is case-sensitive const searchTests = [ 'webhook', 'data', 'automation', 'http', 'workflow', 'processing' ]; searchTests.forEach(query => { const stop = monitor.start(`fts5_search_${query}`); const results = templateRepo.searchTemplates(query, 100); stop(); // Debug output if (results.length === 0) { console.log(`No results for query: ${query}`); // Try to understand what's in the database const count = db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number }; console.log(`Total templates in DB: ${count.count}`); } expect(results.length).toBeGreaterThan(0); }); // All FTS5 searches should be very fast searchTests.forEach(query => { const stats = monitor.getStats(`fts5_search_${query}`); const threshold = process.env.CI ? 50 : 30; expect(stats!.average).toBeLessThan(threshold); }); }); it('should handle complex node type searches efficiently', () => { // Insert templates with various node combinations const nodeTypes = [ 'n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack', 'n8n-nodes-base.googleSheets', 'n8n-nodes-base.mongodb' ]; const templates = Array.from({ length: 5000 }, (_, i) => { const workflow: TemplateWorkflow = { id: i + 1, name: `Template ${i}`, description: `Template description ${i}`, totalViews: 100, createdAt: new Date().toISOString(), user: { id: 1, name: 'Test User', username: 'user', verified: false }, nodes: [] }; const detail: TemplateDetail = { id: i + 1, name: `Template ${i}`, description: `Template description ${i}`, views: 100, createdAt: new Date().toISOString(), workflow: { nodes: Array.from({ length: 3 }, (_, j) => ({ id: `node${j}`, name: `Node ${j}`, type: nodeTypes[(i + j) % nodeTypes.length], typeVersion: 1, position: [100 * j, 100], parameters: {} })), connections: {}, settings: {} } }; return { workflow, detail }; }); const insertTransaction = db.transaction((items: any[]) => { items.forEach(({ workflow, detail }) => templateRepo.saveTemplate(workflow, detail)); }); insertTransaction(templates); // Test searching by node types const stop = monitor.start('search_by_node_types'); const results = templateRepo.getTemplatesByNodes([ 'n8n-nodes-base.webhook', 'n8n-nodes-base.slack' ], 100); stop(); expect(results.length).toBeGreaterThan(0); const stats = monitor.getStats('search_by_node_types'); const threshold = process.env.CI ? 100 : 50; expect(stats!.average).toBeLessThan(threshold); }); }); describe('Database Optimization', () => { it('should benefit from proper indexing', () => { // Insert more data to make index benefits more apparent const nodes = generateNodes(10000); const transaction = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => nodeRepo.saveNode(node)); }); transaction(nodes); // Verify indexes exist const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='nodes'").all() as { name: string }[]; const indexNames = indexes.map(idx => idx.name); expect(indexNames).toContain('idx_package'); expect(indexNames).toContain('idx_category'); expect(indexNames).toContain('idx_ai_tool'); // Test queries that use indexes const indexedQueries = [ { name: 'package_query', query: () => nodeRepo.getNodesByPackage('n8n-nodes-base'), column: 'package_name' }, { name: 'category_query', query: () => nodeRepo.getNodesByCategory('trigger'), column: 'category' }, { name: 'ai_tools_query', query: () => nodeRepo.getAITools(), column: 'is_ai_tool' } ]; // Test indexed queries indexedQueries.forEach(({ name, query, column }) => { // Verify query plan uses index const plan = db.prepare(`EXPLAIN QUERY PLAN SELECT * FROM nodes WHERE ${column} = ?`).all('test') as any[]; const usesIndex = plan.some(row => row.detail && (row.detail.includes('USING INDEX') || row.detail.includes('USING COVERING INDEX')) ); // For simple queries on small datasets, SQLite might choose full table scan // This is expected behavior and doesn't indicate a problem if (!usesIndex && process.env.CI) { console.log(`Note: Query on ${column} may not use index with small dataset (SQLite optimizer decision)`); } const stop = monitor.start(name); const results = query(); stop(); expect(Array.isArray(results)).toBe(true); }); // All queries should be fast regardless of index usage // SQLite's query optimizer makes intelligent decisions indexedQueries.forEach(({ name }) => { const stats = monitor.getStats(name); // Environment-aware thresholds - CI is slower const threshold = process.env.CI ? 100 : 50; expect(stats!.average).toBeLessThan(threshold); }); // Test a non-indexed query for comparison (description column has no index) const stop = monitor.start('non_indexed_query'); const nonIndexedResults = db.prepare("SELECT * FROM nodes WHERE description LIKE ?").all('%webhook%') as any[]; stop(); const nonIndexedStats = monitor.getStats('non_indexed_query'); // Non-indexed queries should still complete reasonably fast with 10k rows const nonIndexedThreshold = process.env.CI ? 200 : 100; expect(nonIndexedStats!.average).toBeLessThan(nonIndexedThreshold); }); it('should handle VACUUM operation efficiently', () => { // Insert and delete data to create fragmentation const nodes = generateNodes(1000); // Insert const insertTx = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => nodeRepo.saveNode(node)); }); insertTx(nodes); // Delete half db.prepare('DELETE FROM nodes WHERE ROWID % 2 = 0').run(); // Measure VACUUM performance const stop = monitor.start('vacuum'); db.exec('VACUUM'); stop(); const stats = monitor.getStats('vacuum'); const threshold = process.env.CI ? 2000 : 1000; expect(stats!.average).toBeLessThan(threshold); // Verify database still works const remaining = nodeRepo.getAllNodes(); expect(remaining.length).toBeGreaterThan(0); }); it('should maintain performance with WAL mode', () => { // Verify WAL mode is enabled const mode = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }; expect(mode.journal_mode).toBe('wal'); // Perform mixed read/write operations const operations = 1000; const stop = monitor.start('wal_mixed_operations'); for (let i = 0; i < operations; i++) { if (i % 10 === 0) { // Write operation const node = generateNodes(1)[0]; nodeRepo.saveNode(node); } else { // Read operation nodeRepo.getAllNodes(10); } } stop(); const stats = monitor.getStats('wal_mixed_operations'); const threshold = process.env.CI ? 1000 : 500; expect(stats!.average).toBeLessThan(threshold); }); }); describe('Memory Usage', () => { it('should handle large result sets without excessive memory', () => { // Insert large dataset const nodes = generateNodes(10000); const transaction = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => nodeRepo.saveNode(node)); }); transaction(nodes); // Measure memory before const memBefore = process.memoryUsage().heapUsed; // Fetch large result set const stop = monitor.start('large_result_set'); const results = nodeRepo.getAllNodes(); stop(); // Measure memory after const memAfter = process.memoryUsage().heapUsed; const memIncrease = (memAfter - memBefore) / 1024 / 1024; // MB expect(results).toHaveLength(10000); expect(memIncrease).toBeLessThan(100); // Less than 100MB increase const stats = monitor.getStats('large_result_set'); const threshold = process.env.CI ? 400 : 200; expect(stats!.average).toBeLessThan(threshold); }); }); describe('Concurrent Write Performance', () => { it('should handle concurrent writes with transactions', () => { const writeBatches = 10; const nodesPerBatch = 100; const stop = monitor.start('concurrent_writes'); // Simulate concurrent write batches const promises = Array.from({ length: writeBatches }, (_, i) => { return new Promise<void>((resolve) => { const nodes = generateNodes(nodesPerBatch, i * nodesPerBatch); const transaction = db.transaction((nodes: ParsedNode[]) => { nodes.forEach(node => nodeRepo.saveNode(node)); }); transaction(nodes); resolve(); }); }); Promise.all(promises); stop(); const stats = monitor.getStats('concurrent_writes'); const threshold = process.env.CI ? 1000 : 500; expect(stats!.average).toBeLessThan(threshold); // Verify all nodes were written const count = nodeRepo.getNodeCount(); expect(count).toBe(writeBatches * nodesPerBatch); }); }); }); // Helper functions function generateNodes(count: number, startId: number = 0): ParsedNode[] { const categories = ['trigger', 'automation', 'transform', 'output']; const packages = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']; return Array.from({ length: count }, (_, i) => ({ nodeType: `n8n-nodes-base.node${startId + i}`, packageName: packages[i % packages.length], displayName: `Node ${startId + i}`, description: `Description for node ${startId + i} with ${['webhook', 'http', 'automation', 'data'][i % 4]} functionality`, category: categories[i % categories.length], style: 'programmatic' as const, isAITool: i % 10 === 0, isTrigger: categories[i % categories.length] === 'trigger', isWebhook: i % 5 === 0, isVersioned: true, version: '1', documentation: i % 3 === 0 ? `Documentation for node ${i}` : undefined, properties: Array.from({ length: 5 }, (_, j) => ({ displayName: `Property ${j}`, name: `prop${j}`, type: 'string', default: '' })), operations: [], credentials: i % 4 === 0 ? [{ name: 'httpAuth', required: true }] : [], // Add fullNodeType for search compatibility fullNodeType: `n8n-nodes-base.node${startId + i}` })); } function generateDescription(index: number): string { const descriptions = [ 'Automate your workflow with powerful webhook integrations', 'Process http requests and transform data efficiently', 'Connect to external APIs and sync data seamlessly', 'Build complex automation workflows with ease', 'Transform and filter data with advanced processing operations' ]; return descriptions[index % descriptions.length] + ` - Version ${index}`; } // Generate nodes with searchable content for search tests function generateSearchableNodes(count: number): ParsedNode[] { const searchTerms = ['webhook', 'http', 'request', 'automation', 'data', 'HTTP']; const categories = ['trigger', 'automation', 'transform', 'output']; const packages = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']; return Array.from({ length: count }, (_, i) => { // Ensure some nodes match our search terms const termIndex = i % searchTerms.length; const searchTerm = searchTerms[termIndex]; return { nodeType: `n8n-nodes-base.${searchTerm}Node${i}`, packageName: packages[i % packages.length], displayName: `${searchTerm} Node ${i}`, description: `${searchTerm} functionality for ${searchTerms[(i + 1) % searchTerms.length]} operations`, category: categories[i % categories.length], style: 'programmatic' as const, isAITool: i % 10 === 0, isTrigger: categories[i % categories.length] === 'trigger', isWebhook: searchTerm === 'webhook' || i % 5 === 0, isVersioned: true, version: '1', documentation: i % 3 === 0 ? `Documentation for ${searchTerm} node ${i}` : undefined, properties: Array.from({ length: 5 }, (_, j) => ({ displayName: `Property ${j}`, name: `prop${j}`, type: 'string', default: '' })), operations: [], credentials: i % 4 === 0 ? [{ name: 'httpAuth', required: true }] : [] }; }); } ``` -------------------------------------------------------------------------------- /tests/unit/services/enhanced-config-validator-integration.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; import { ResourceSimilarityService } from '@/services/resource-similarity-service'; import { OperationSimilarityService } from '@/services/operation-similarity-service'; import { NodeRepository } from '@/database/node-repository'; // Mock similarity services vi.mock('@/services/resource-similarity-service'); vi.mock('@/services/operation-similarity-service'); describe('EnhancedConfigValidator - Integration Tests', () => { let mockResourceService: any; let mockOperationService: any; let mockRepository: any; beforeEach(() => { mockRepository = { getNode: vi.fn(), getNodeOperations: vi.fn().mockReturnValue([]), getNodeResources: vi.fn().mockReturnValue([]), getOperationsForResource: vi.fn().mockReturnValue([]), getDefaultOperationForResource: vi.fn().mockReturnValue(undefined), getNodePropertyDefaults: vi.fn().mockReturnValue({}) }; mockResourceService = { findSimilarResources: vi.fn().mockReturnValue([]) }; mockOperationService = { findSimilarOperations: vi.fn().mockReturnValue([]) }; // Mock the constructors to return our mock services vi.mocked(ResourceSimilarityService).mockImplementation(() => mockResourceService); vi.mocked(OperationSimilarityService).mockImplementation(() => mockOperationService); // Initialize the similarity services (this will create the service instances) EnhancedConfigValidator.initializeSimilarityServices(mockRepository); }); afterEach(() => { vi.clearAllMocks(); }); describe('similarity service integration', () => { it('should initialize similarity services when initializeSimilarityServices is called', () => { // Services should be created when initializeSimilarityServices was called in beforeEach expect(ResourceSimilarityService).toHaveBeenCalled(); expect(OperationSimilarityService).toHaveBeenCalled(); }); it('should use resource similarity service for invalid resource errors', () => { const config = { resource: 'invalidResource', operation: 'send' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' } ] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [ { value: 'send', name: 'Send Message' } ] } ]; // Mock resource similarity suggestions mockResourceService.findSimilarResources.mockReturnValue([ { value: 'message', confidence: 0.8, reason: 'Similar resource name', availableOperations: ['send', 'update'] } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith( 'nodes-base.slack', 'invalidResource', expect.any(Number) ); // Should have suggestions in the result expect(result.suggestions).toBeDefined(); expect(result.suggestions.length).toBeGreaterThan(0); }); it('should use operation similarity service for invalid operation errors', () => { const config = { resource: 'message', operation: 'invalidOperation' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [ { value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' } ] } ]; // Mock operation similarity suggestions mockOperationService.findSimilarOperations.mockReturnValue([ { value: 'send', confidence: 0.9, reason: 'Very similar - likely a typo', resource: 'message' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); expect(mockOperationService.findSimilarOperations).toHaveBeenCalledWith( 'nodes-base.slack', 'invalidOperation', 'message', expect.any(Number) ); // Should have suggestions in the result expect(result.suggestions).toBeDefined(); expect(result.suggestions.length).toBeGreaterThan(0); }); it('should handle similarity service errors gracefully', () => { const config = { resource: 'invalidResource', operation: 'send' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] } ]; // Mock service to throw error mockResourceService.findSimilarResources.mockImplementation(() => { throw new Error('Service error'); }); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); // Should not crash and still provide basic validation expect(result).toBeDefined(); expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); }); it('should not call similarity services for valid configurations', () => { // Mock repository to return valid resources for this test mockRepository.getNodeResources.mockReturnValue([ { value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' } ]); // Mock getNodeOperations to return valid operations mockRepository.getNodeOperations.mockReturnValue([ { value: 'send', name: 'Send Message' } ]); const config = { resource: 'message', operation: 'send', channel: '#general', // Add required field for Slack send text: 'Test message' // Add required field for Slack send }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [ { value: 'send', name: 'Send Message' } ] } ]; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); // Should not call similarity services for valid config expect(mockResourceService.findSimilarResources).not.toHaveBeenCalled(); expect(mockOperationService.findSimilarOperations).not.toHaveBeenCalled(); expect(result.valid).toBe(true); }); it('should limit suggestion count when calling similarity services', () => { const config = { resource: 'invalidResource' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] } ]; EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith( 'nodes-base.slack', 'invalidResource', 3 // Should limit to 3 suggestions ); }); }); describe('error enhancement with suggestions', () => { it('should enhance resource validation errors with suggestions', () => { const config = { resource: 'msgs' // Typo for 'message' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' } ] } ]; // Mock high-confidence suggestion mockResourceService.findSimilarResources.mockReturnValue([ { value: 'message', confidence: 0.85, reason: 'Very similar - likely a typo', availableOperations: ['send', 'update', 'delete'] } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); // Should have enhanced error with suggestion const resourceError = result.errors.find(e => e.property === 'resource'); expect(resourceError).toBeDefined(); expect(resourceError!.suggestion).toBeDefined(); expect(resourceError!.suggestion).toContain('message'); }); it('should enhance operation validation errors with suggestions', () => { const config = { resource: 'message', operation: 'sned' // Typo for 'send' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [ { value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' } ] } ]; // Mock high-confidence suggestion mockOperationService.findSimilarOperations.mockReturnValue([ { value: 'send', confidence: 0.9, reason: 'Almost exact match - likely a typo', resource: 'message', description: 'Send Message' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); // Should have enhanced error with suggestion const operationError = result.errors.find(e => e.property === 'operation'); expect(operationError).toBeDefined(); expect(operationError!.suggestion).toBeDefined(); expect(operationError!.suggestion).toContain('send'); }); it('should not enhance errors when no good suggestions are available', () => { const config = { resource: 'completelyWrongValue' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] } ]; // Mock low-confidence suggestions mockResourceService.findSimilarResources.mockReturnValue([ { value: 'message', confidence: 0.2, // Too low confidence reason: 'Possibly related resource' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); // Should not enhance error due to low confidence const resourceError = result.errors.find(e => e.property === 'resource'); expect(resourceError).toBeDefined(); expect(resourceError!.suggestion).toBeUndefined(); }); it('should provide multiple operation suggestions when resource is known', () => { const config = { resource: 'message', operation: 'invalidOp' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [ { value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' }, { value: 'delete', name: 'Delete Message' } ] } ]; // Mock multiple suggestions mockOperationService.findSimilarOperations.mockReturnValue([ { value: 'send', confidence: 0.7, reason: 'Similar operation' }, { value: 'update', confidence: 0.6, reason: 'Similar operation' }, { value: 'delete', confidence: 0.5, reason: 'Similar operation' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); // Should include multiple suggestions in the result expect(result.suggestions.length).toBeGreaterThan(2); const operationSuggestions = result.suggestions.filter(s => s.includes('send') || s.includes('update') || s.includes('delete') ); expect(operationSuggestions.length).toBeGreaterThan(0); }); }); describe('confidence thresholds and filtering', () => { it('should only use high confidence resource suggestions', () => { const config = { resource: 'invalidResource' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] } ]; // Mock mixed confidence suggestions mockResourceService.findSimilarResources.mockReturnValue([ { value: 'message1', confidence: 0.9, reason: 'High confidence' }, { value: 'message2', confidence: 0.4, reason: 'Low confidence' }, { value: 'message3', confidence: 0.7, reason: 'Medium confidence' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); // Should only use suggestions above threshold const resourceError = result.errors.find(e => e.property === 'resource'); expect(resourceError?.suggestion).toBeDefined(); // Should prefer high confidence suggestion expect(resourceError!.suggestion).toContain('message1'); }); it('should only use high confidence operation suggestions', () => { const config = { resource: 'message', operation: 'invalidOperation' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [ { value: 'send', name: 'Send Message' } ] } ]; // Mock mixed confidence suggestions mockOperationService.findSimilarOperations.mockReturnValue([ { value: 'send', confidence: 0.95, reason: 'Very high confidence' }, { value: 'post', confidence: 0.3, reason: 'Low confidence' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); // Should only use high confidence suggestion const operationError = result.errors.find(e => e.property === 'operation'); expect(operationError?.suggestion).toBeDefined(); expect(operationError!.suggestion).toContain('send'); expect(operationError!.suggestion).not.toContain('post'); }); }); describe('integration with existing validation logic', () => { it('should work with minimal validation mode', () => { // Mock repository to return empty resources mockRepository.getNodeResources.mockReturnValue([]); const config = { resource: 'invalidResource' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] } ]; mockResourceService.findSimilarResources.mockReturnValue([ { value: 'message', confidence: 0.8, reason: 'Similar' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'minimal', 'ai-friendly' ); // Should still enhance errors in minimal mode expect(mockResourceService.findSimilarResources).toHaveBeenCalled(); expect(result.errors.length).toBeGreaterThan(0); }); it('should work with strict validation profile', () => { // Mock repository to return valid resource but no operations mockRepository.getNodeResources.mockReturnValue([ { value: 'message', name: 'Message' } ]); mockRepository.getOperationsForResource.mockReturnValue([]); const config = { resource: 'message', operation: 'invalidOp' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [ { value: 'send', name: 'Send Message' } ] } ]; mockOperationService.findSimilarOperations.mockReturnValue([ { value: 'send', confidence: 0.8, reason: 'Similar' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'strict' ); // Should enhance errors regardless of profile expect(mockOperationService.findSimilarOperations).toHaveBeenCalled(); const operationError = result.errors.find(e => e.property === 'operation'); expect(operationError?.suggestion).toBeDefined(); }); it('should preserve original error properties when enhancing', () => { const config = { resource: 'invalidResource' }; const properties = [ { name: 'resource', type: 'options', required: true, options: [ { value: 'message', name: 'Message' } ] } ]; mockResourceService.findSimilarResources.mockReturnValue([ { value: 'message', confidence: 0.8, reason: 'Similar' } ]); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.slack', config, properties, 'operation', 'ai-friendly' ); const resourceError = result.errors.find(e => e.property === 'resource'); // Should preserve original error properties expect(resourceError?.type).toBeDefined(); expect(resourceError?.property).toBe('resource'); expect(resourceError?.message).toBeDefined(); // Should add suggestion without overriding other properties expect(resourceError?.suggestion).toBeDefined(); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/templates/metadata-operations.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TemplateService } from '../../../src/templates/template-service'; import { TemplateRepository } from '../../../src/templates/template-repository'; import { MetadataGenerator } from '../../../src/templates/metadata-generator'; import { BatchProcessor } from '../../../src/templates/batch-processor'; import { DatabaseAdapter, createDatabaseAdapter } from '../../../src/database/database-adapter'; import { tmpdir } from 'os'; import * as path from 'path'; import { unlinkSync, existsSync, readFileSync } from 'fs'; // Mock logger vi.mock('../../../src/utils/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } })); // Mock template sanitizer vi.mock('../../../src/utils/template-sanitizer', () => { class MockTemplateSanitizer { sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false })); detectTokens = vi.fn(() => []); } return { TemplateSanitizer: MockTemplateSanitizer }; }); // Mock OpenAI for MetadataGenerator and BatchProcessor vi.mock('openai', () => { const mockClient = { chat: { completions: { create: vi.fn() } }, files: { create: vi.fn(), content: vi.fn(), del: vi.fn() }, batches: { create: vi.fn(), retrieve: vi.fn() } }; return { default: vi.fn().mockImplementation(() => mockClient) }; }); describe('Template Metadata Operations - Integration Tests', () => { let adapter: DatabaseAdapter; let repository: TemplateRepository; let service: TemplateService; let dbPath: string; beforeEach(async () => { // Create temporary database dbPath = path.join(tmpdir(), `test-metadata-${Date.now()}.db`); adapter = await createDatabaseAdapter(dbPath); // Initialize database schema const schemaPath = path.join(__dirname, '../../../src/database/schema.sql'); const schema = readFileSync(schemaPath, 'utf8'); adapter.exec(schema); // Initialize repository and service repository = new TemplateRepository(adapter); service = new TemplateService(adapter); // Create test templates await createTestTemplates(); }); afterEach(() => { if (adapter) { adapter.close(); } if (existsSync(dbPath)) { unlinkSync(dbPath); } vi.clearAllMocks(); }); async function createTestTemplates() { // Create test templates with metadata const templates = [ { workflow: { id: 1, name: 'Simple Webhook Slack', description: 'Basic webhook to Slack automation', user: { id: 1, name: 'Test User', username: 'test', verified: true }, nodes: [ { id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' }, { id: 2, name: 'n8n-nodes-base.slack', icon: 'fa:slack' } ], totalViews: 150, createdAt: '2024-01-01T00:00:00Z' }, detail: { id: 1, name: 'Simple Webhook Slack', description: 'Basic webhook to Slack automation', views: 150, createdAt: '2024-01-01T00:00:00Z', workflow: { nodes: [ { type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }, { type: 'n8n-nodes-base.slack', name: 'Slack', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 } ], connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } }, settings: {} } }, categories: ['automation', 'communication'], metadata: { categories: ['automation', 'communication'], complexity: 'simple' as const, use_cases: ['Webhook processing', 'Slack notifications'], estimated_setup_minutes: 15, required_services: ['Slack API'], key_features: ['Real-time notifications', 'Easy setup'], target_audience: ['developers', 'marketers'] } }, { workflow: { id: 2, name: 'Complex AI Data Pipeline', description: 'Advanced data processing with AI analysis', user: { id: 2, name: 'AI Expert', username: 'aiexpert', verified: true }, nodes: [ { id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' }, { id: 2, name: '@n8n/n8n-nodes-langchain.openAi', icon: 'fa:brain' }, { id: 3, name: 'n8n-nodes-base.postgres', icon: 'fa:database' }, { id: 4, name: 'n8n-nodes-base.googleSheets', icon: 'fa:sheet' } ], totalViews: 450, createdAt: '2024-01-15T00:00:00Z' }, detail: { id: 2, name: 'Complex AI Data Pipeline', description: 'Advanced data processing with AI analysis', views: 450, createdAt: '2024-01-15T00:00:00Z', workflow: { nodes: [ { type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }, { type: '@n8n/n8n-nodes-langchain.openAi', name: 'OpenAI', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 }, { type: 'n8n-nodes-base.postgres', name: 'Postgres', id: '3', position: [200, 0], parameters: {}, typeVersion: 1 }, { type: 'n8n-nodes-base.googleSheets', name: 'Google Sheets', id: '4', position: [300, 0], parameters: {}, typeVersion: 1 } ], connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] }, '2': { main: [[{ node: '3', type: 'main', index: 0 }]] }, '3': { main: [[{ node: '4', type: 'main', index: 0 }]] } }, settings: {} } }, categories: ['ai', 'data_processing'], metadata: { categories: ['ai', 'data_processing', 'automation'], complexity: 'complex' as const, use_cases: ['Data analysis', 'AI processing', 'Report generation'], estimated_setup_minutes: 120, required_services: ['OpenAI API', 'PostgreSQL', 'Google Sheets API'], key_features: ['AI analysis', 'Database integration', 'Automated reports'], target_audience: ['developers', 'analysts'] } }, { workflow: { id: 3, name: 'Medium Email Automation', description: 'Email automation with moderate complexity', user: { id: 3, name: 'Marketing User', username: 'marketing', verified: false }, nodes: [ { id: 1, name: 'n8n-nodes-base.cron', icon: 'fa:clock' }, { id: 2, name: 'n8n-nodes-base.gmail', icon: 'fa:mail' }, { id: 3, name: 'n8n-nodes-base.googleSheets', icon: 'fa:sheet' } ], totalViews: 200, createdAt: '2024-02-01T00:00:00Z' }, detail: { id: 3, name: 'Medium Email Automation', description: 'Email automation with moderate complexity', views: 200, createdAt: '2024-02-01T00:00:00Z', workflow: { nodes: [ { type: 'n8n-nodes-base.cron', name: 'Cron', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }, { type: 'n8n-nodes-base.gmail', name: 'Gmail', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 }, { type: 'n8n-nodes-base.googleSheets', name: 'Google Sheets', id: '3', position: [200, 0], parameters: {}, typeVersion: 1 } ], connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] }, '2': { main: [[{ node: '3', type: 'main', index: 0 }]] } }, settings: {} } }, categories: ['email_automation', 'scheduling'], metadata: { categories: ['email_automation', 'scheduling'], complexity: 'medium' as const, use_cases: ['Email campaigns', 'Scheduled reports'], estimated_setup_minutes: 45, required_services: ['Gmail API', 'Google Sheets API'], key_features: ['Scheduled execution', 'Email automation'], target_audience: ['marketers'] } } ]; // Save templates for (const template of templates) { repository.saveTemplate(template.workflow, template.detail, template.categories); repository.updateTemplateMetadata(template.workflow.id, template.metadata); } } describe('Repository Metadata Operations', () => { it('should update template metadata successfully', () => { const newMetadata = { categories: ['test', 'updated'], complexity: 'simple' as const, use_cases: ['Testing'], estimated_setup_minutes: 10, required_services: [], key_features: ['Test feature'], target_audience: ['testers'] }; repository.updateTemplateMetadata(1, newMetadata); // Verify metadata was updated const templates = repository.searchTemplatesByMetadata({ category: 'test'}, 10, 0); expect(templates).toHaveLength(1); expect(templates[0].id).toBe(1); }); it('should batch update metadata for multiple templates', () => { const metadataMap = new Map([ [1, { categories: ['batch_test'], complexity: 'simple' as const, use_cases: ['Batch testing'], estimated_setup_minutes: 20, required_services: [], key_features: ['Batch update'], target_audience: ['developers'] }], [2, { categories: ['batch_test'], complexity: 'complex' as const, use_cases: ['Complex batch testing'], estimated_setup_minutes: 60, required_services: ['OpenAI'], key_features: ['Advanced batch'], target_audience: ['developers'] }] ]); repository.batchUpdateMetadata(metadataMap); // Verify both templates were updated const templates = repository.searchTemplatesByMetadata({ category: 'batch_test'}, 10, 0); expect(templates).toHaveLength(2); expect(templates.map(t => t.id).sort()).toEqual([1, 2]); }); it('should search templates by category', () => { const templates = repository.searchTemplatesByMetadata({ category: 'automation'}, 10, 0); expect(templates.length).toBeGreaterThan(0); expect(templates[0]).toHaveProperty('id'); expect(templates[0]).toHaveProperty('name'); }); it('should search templates by complexity', () => { const simpleTemplates = repository.searchTemplatesByMetadata({ complexity: 'simple'}, 10, 0); const complexTemplates = repository.searchTemplatesByMetadata({ complexity: 'complex'}, 10, 0); expect(simpleTemplates).toHaveLength(1); expect(complexTemplates).toHaveLength(1); expect(simpleTemplates[0].id).toBe(1); expect(complexTemplates[0].id).toBe(2); }); it('should search templates by setup time', () => { const quickTemplates = repository.searchTemplatesByMetadata({ maxSetupMinutes: 30}, 10, 0); const longTemplates = repository.searchTemplatesByMetadata({ minSetupMinutes: 60}, 10, 0); expect(quickTemplates).toHaveLength(1); // Only 15 min template (45 min > 30) expect(longTemplates).toHaveLength(1); // 120 min template }); it('should search templates by required service', () => { const slackTemplates = repository.searchTemplatesByMetadata({ requiredService: 'slack'}, 10, 0); const openaiTemplates = repository.searchTemplatesByMetadata({ requiredService: 'OpenAI'}, 10, 0); expect(slackTemplates).toHaveLength(1); expect(openaiTemplates).toHaveLength(1); }); it('should search templates by target audience', () => { const developerTemplates = repository.searchTemplatesByMetadata({ targetAudience: 'developers'}, 10, 0); const marketerTemplates = repository.searchTemplatesByMetadata({ targetAudience: 'marketers'}, 10, 0); expect(developerTemplates).toHaveLength(2); expect(marketerTemplates).toHaveLength(2); }); it('should handle combined filters correctly', () => { const filteredTemplates = repository.searchTemplatesByMetadata({ complexity: 'medium', targetAudience: 'marketers', maxSetupMinutes: 60}, 10, 0); expect(filteredTemplates).toHaveLength(1); expect(filteredTemplates[0].id).toBe(3); }); it('should return correct counts for metadata searches', () => { const automationCount = repository.getSearchTemplatesByMetadataCount({ category: 'automation' }); const complexCount = repository.getSearchTemplatesByMetadataCount({ complexity: 'complex' }); expect(automationCount).toBeGreaterThan(0); expect(complexCount).toBe(1); }); it('should get unique categories', () => { const categories = repository.getUniqueCategories(); expect(categories).toContain('automation'); expect(categories).toContain('communication'); expect(categories).toContain('ai'); expect(categories).toContain('data_processing'); expect(categories).toContain('email_automation'); expect(categories).toContain('scheduling'); }); it('should get unique target audiences', () => { const audiences = repository.getUniqueTargetAudiences(); expect(audiences).toContain('developers'); expect(audiences).toContain('marketers'); expect(audiences).toContain('analysts'); }); it('should get templates by category', () => { const aiTemplates = repository.getTemplatesByCategory('ai'); // Both template 2 has 'ai', and template 1 has 'automation' which contains 'ai' as substring // due to LIKE '%ai%' matching expect(aiTemplates).toHaveLength(2); // Template 2 should be first due to higher view count (450 vs 150) expect(aiTemplates[0].id).toBe(2); }); it('should get templates by complexity', () => { const simpleTemplates = repository.getTemplatesByComplexity('simple'); expect(simpleTemplates).toHaveLength(1); expect(simpleTemplates[0].id).toBe(1); }); it('should get templates without metadata', () => { // Create a template without metadata const workflow = { id: 999, name: 'No Metadata Template', description: 'Template without metadata', user: { id: 999, name: 'Test', username: 'test', verified: true }, nodes: [{ id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' }], totalViews: 50, // Must be > 10 to not be filtered out createdAt: '2024-03-01T00:00:00Z' }; const detail = { id: 999, name: 'No Metadata Template', description: 'Template without metadata', views: 50, // Must be > 10 to not be filtered out createdAt: '2024-03-01T00:00:00Z', workflow: { nodes: [{ type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }], connections: {}, settings: {} } }; repository.saveTemplate(workflow, detail, []); // Don't update metadata for this template, so it remains without metadata const templatesWithoutMetadata = repository.getTemplatesWithoutMetadata(); expect(templatesWithoutMetadata.some(t => t.workflow_id === 999)).toBe(true); }); it('should get outdated metadata templates', () => { // This test would require manipulating timestamps, // for now just verify the method doesn't throw const outdatedTemplates = repository.getTemplatesWithOutdatedMetadata(30); expect(Array.isArray(outdatedTemplates)).toBe(true); }); it('should get metadata statistics', () => { const stats = repository.getMetadataStats(); expect(stats).toHaveProperty('withMetadata'); expect(stats).toHaveProperty('total'); expect(stats).toHaveProperty('withoutMetadata'); expect(stats).toHaveProperty('outdated'); expect(stats.withMetadata).toBeGreaterThan(0); expect(stats.total).toBeGreaterThan(0); }); }); describe('Service Layer Integration', () => { it('should search templates with metadata through service', async () => { const results = await service.searchTemplatesByMetadata({ complexity: 'simple'}, 10, 0); expect(results).toHaveProperty('items'); expect(results).toHaveProperty('total'); expect(results).toHaveProperty('hasMore'); expect(results.items.length).toBeGreaterThan(0); expect(results.items[0]).toHaveProperty('metadata'); }); it('should handle pagination correctly in metadata search', async () => { const page1 = await service.searchTemplatesByMetadata( {}, // empty filters 1, // limit 0 // offset ); const page2 = await service.searchTemplatesByMetadata( {}, // empty filters 1, // limit 1 // offset ); expect(page1.items).toHaveLength(1); expect(page2.items).toHaveLength(1); expect(page1.items[0].id).not.toBe(page2.items[0].id); }); it('should return templates with metadata information', async () => { const results = await service.searchTemplatesByMetadata({ category: 'automation'}, 10, 0); expect(results.items.length).toBeGreaterThan(0); const template = results.items[0]; expect(template).toHaveProperty('metadata'); expect(template.metadata).toHaveProperty('categories'); expect(template.metadata).toHaveProperty('complexity'); expect(template.metadata).toHaveProperty('estimated_setup_minutes'); }); }); describe('Security and Error Handling', () => { it('should handle malicious input safely in metadata search', () => { const maliciousInputs = [ { category: "'; DROP TABLE templates; --" }, { requiredService: "'; UNION SELECT * FROM sqlite_master; --" }, { targetAudience: "administrators'; DELETE FROM templates WHERE '1'='1" } ]; maliciousInputs.forEach(input => { expect(() => { repository.searchTemplatesByMetadata({ ...input}, 10, 0); }).not.toThrow(); }); }); it('should handle invalid metadata gracefully', () => { const invalidMetadata = { categories: null, complexity: 'invalid_complexity', use_cases: 'not_an_array', estimated_setup_minutes: 'not_a_number', required_services: undefined, key_features: {}, target_audience: 42 }; expect(() => { repository.updateTemplateMetadata(1, invalidMetadata); }).not.toThrow(); }); it('should handle empty search results gracefully', () => { const results = repository.searchTemplatesByMetadata({ category: 'nonexistent_category'}, 10, 0); expect(results).toHaveLength(0); }); it('should handle edge case parameters', () => { // Test extreme values const results = repository.searchTemplatesByMetadata({ maxSetupMinutes: 0, minSetupMinutes: 999999 }, 0, -1); // offset -1 to test edge case expect(Array.isArray(results)).toBe(true); }); }); describe('Performance and Scalability', () => { it('should handle large result sets efficiently', () => { // Test with maximum limit const startTime = Date.now(); const results = repository.searchTemplatesByMetadata({}, 100, 0); const endTime = Date.now(); expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second expect(Array.isArray(results)).toBe(true); }); it('should handle concurrent metadata updates', () => { const updates: any[] = []; for (let i = 0; i < 10; i++) { updates.push(() => { repository.updateTemplateMetadata(1, { categories: [`concurrent_test_${i}`], complexity: 'simple' as const, use_cases: ['Testing'], estimated_setup_minutes: 10, required_services: [], key_features: ['Concurrent'], target_audience: ['developers'] }); }); } // Execute all updates expect(() => { updates.forEach(update => update()); }).not.toThrow(); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/workflow-validator-edge-cases.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { WorkflowValidator } from '@/services/workflow-validator'; import { NodeRepository } from '@/database/node-repository'; import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; import type { WorkflowValidationResult } from '@/services/workflow-validator'; // NOTE: Mocking EnhancedConfigValidator is challenging because: // 1. WorkflowValidator expects the class itself, not an instance // 2. The class has static methods that are called directly // 3. vi.mock() hoisting makes it difficult to mock properly // // For properly mocked tests, see workflow-validator-with-mocks.test.ts // These tests use a partially mocked approach that may still access the database // Mock dependencies vi.mock('@/database/node-repository'); vi.mock('@/services/expression-validator'); vi.mock('@/utils/logger'); // Mock EnhancedConfigValidator with static methods vi.mock('@/services/enhanced-config-validator', () => ({ EnhancedConfigValidator: { validate: vi.fn().mockReturnValue({ valid: true, errors: [], warnings: [], suggestions: [], visibleProperties: [], hiddenProperties: [] }), validateWithMode: vi.fn().mockReturnValue({ valid: true, errors: [], warnings: [], fixedConfig: null }) } })); describe('WorkflowValidator - Edge Cases', () => { let validator: WorkflowValidator; let mockNodeRepository: any; let mockEnhancedConfigValidator: any; beforeEach(() => { vi.clearAllMocks(); // Create mock repository that returns node info for test nodes and common n8n nodes mockNodeRepository = { getNode: vi.fn().mockImplementation((type: string) => { if (type === 'test.node' || type === 'test.agent' || type === 'test.tool') { return { name: 'Test Node', type: type, typeVersion: 1, properties: [], package: 'test-package', version: 1, displayName: 'Test Node', isVersioned: false }; } // Handle common n8n node types if (type.startsWith('n8n-nodes-base.') || type.startsWith('nodes-base.')) { const nodeName = type.split('.')[1]; return { name: nodeName, type: type, typeVersion: 1, properties: [], package: 'n8n-nodes-base', version: 1, displayName: nodeName.charAt(0).toUpperCase() + nodeName.slice(1), isVersioned: ['set', 'httpRequest'].includes(nodeName) }; } return null; }), findByType: vi.fn().mockReturnValue({ name: 'Test Node', type: 'test.node', typeVersion: 1, properties: [] }), searchNodes: vi.fn().mockReturnValue([]) }; // Ensure EnhancedConfigValidator.validate always returns a valid result vi.mocked(EnhancedConfigValidator.validate).mockReturnValue({ valid: true, errors: [], warnings: [], suggestions: [], visibleProperties: [], hiddenProperties: [] }); // Create validator instance with mocked dependencies validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); }); describe('Null and Undefined Handling', () => { it('should handle null workflow gracefully', async () => { const result = await validator.validateWorkflow(null as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true); }); it('should handle undefined workflow gracefully', async () => { const result = await validator.validateWorkflow(undefined as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true); }); it('should handle workflow with null nodes array', async () => { const workflow = { nodes: null, connections: {} }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('nodes must be an array'))).toBe(true); }); it('should handle workflow with null connections', async () => { const workflow = { nodes: [], connections: null }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.some(e => e.message.includes('connections must be an object'))).toBe(true); }); it('should handle nodes with null/undefined properties', async () => { const workflow = { nodes: [ { id: '1', name: null, type: 'test.node', position: [0, 0], parameters: undefined } ], connections: {} }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); }); }); describe('Boundary Value Testing', () => { it('should handle empty workflow', async () => { const workflow = { nodes: [], connections: {} }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(true); expect(result.warnings.some(w => w.message.includes('empty'))).toBe(true); }); it('should handle very large workflows', async () => { const nodes = Array(1000).fill(null).map((_, i) => ({ id: `node${i}`, name: `Node ${i}`, type: 'test.node', position: [i * 100, 0] as [number, number], parameters: {} })); const connections: any = {}; for (let i = 0; i < 999; i++) { connections[`Node ${i}`] = { main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]] }; } const workflow = { nodes, connections }; const start = Date.now(); const result = await validator.validateWorkflow(workflow as any); const duration = Date.now() - start; expect(result).toBeDefined(); // Use longer timeout for CI environments const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'; const timeout = isCI ? 10000 : 5000; // 10 seconds for CI, 5 seconds for local expect(duration).toBeLessThan(timeout); }); it('should handle deeply nested connections', async () => { const workflow = { nodes: [ { id: '1', name: 'Start', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'Middle', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }, { id: '3', name: 'End', type: 'test.node', position: [200, 0] as [number, number], parameters: {} } ], connections: { 'Start': { main: [[{ node: 'Middle', type: 'main', index: 0 }]], error: [[{ node: 'End', type: 'main', index: 0 }]], ai_tool: [[{ node: 'Middle', type: 'ai_tool', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.statistics.invalidConnections).toBe(0); }); it.skip('should handle nodes at extreme positions - FIXME: mock issues', async () => { const workflow = { nodes: [ { id: '1', name: 'FarLeft', type: 'n8n-nodes-base.set', position: [-999999, -999999] as [number, number], parameters: {} }, { id: '2', name: 'FarRight', type: 'n8n-nodes-base.set', position: [999999, 999999] as [number, number], parameters: {} }, { id: '3', name: 'Zero', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} } ], connections: { 'FarLeft': { main: [[{ node: 'FarRight', type: 'main', index: 0 }]] }, 'FarRight': { main: [[{ node: 'Zero', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(true); }); }); describe('Invalid Data Type Handling', () => { it('should handle non-array nodes', async () => { const workflow = { nodes: 'not-an-array', connections: {} }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors[0].message).toContain('nodes must be an array'); }); it('should handle non-object connections', async () => { const workflow = { nodes: [], connections: [] }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(false); expect(result.errors[0].message).toContain('connections must be an object'); }); it('should handle invalid position values', async () => { const workflow = { nodes: [ { id: '1', name: 'InvalidPos', type: 'test.node', position: 'invalid' as any, parameters: {} }, { id: '2', name: 'NaNPos', type: 'test.node', position: [NaN, NaN] as [number, number], parameters: {} }, { id: '3', name: 'InfinityPos', type: 'test.node', position: [Infinity, -Infinity] as [number, number], parameters: {} } ], connections: {} }; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.length).toBeGreaterThan(0); }); it('should handle circular references in workflow object', async () => { const workflow: any = { nodes: [], connections: {} }; workflow.circular = workflow; await expect(validator.validateWorkflow(workflow)).resolves.toBeDefined(); }); }); describe('Connection Validation Edge Cases', () => { it('should detect self-referencing nodes', async () => { const workflow = { nodes: [ { id: '1', name: 'SelfLoop', type: 'test.node', position: [0, 0] as [number, number], parameters: {} } ], connections: { 'SelfLoop': { main: [[{ node: 'SelfLoop', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('self-referencing'))).toBe(true); }); it('should handle non-existent node references', async () => { const workflow = { nodes: [ { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} } ], connections: { 'Node1': { main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('non-existent'))).toBe(true); }); it('should handle invalid connection formats', async () => { const workflow = { nodes: [ { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} } ], connections: { 'Node1': { main: 'invalid-format' as any } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.length).toBeGreaterThan(0); }); it('should handle missing connection properties', async () => { const workflow = { nodes: [ { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} } ], connections: { 'Node1': { main: [[{ node: 'Node2' }]] // Missing type and index } } as any }; const result = await validator.validateWorkflow(workflow as any); // Should still work as type and index can have defaults expect(result.statistics.validConnections).toBeGreaterThan(0); }); it('should handle negative output indices', async () => { const workflow = { nodes: [ { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} } ], connections: { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: -1 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.some(e => e.message.includes('Invalid'))).toBe(true); }); }); describe('Special Characters and Unicode', () => { // Note: These tests are skipped because WorkflowValidator also needs special character // normalization (similar to WorkflowDiffEngine fix in #270). Will be addressed in a future PR. it.skip('should handle apostrophes in node names - TODO: needs WorkflowValidator normalization', async () => { // Test default n8n Manual Trigger node name with apostrophes const workflow = { nodes: [ { id: '1', name: "When clicking 'Execute workflow'", type: 'n8n-nodes-base.manualTrigger', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 0] as [number, number], parameters: {} } ], connections: { "When clicking 'Execute workflow'": { main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); it.skip('should handle special characters in node names - TODO: needs WorkflowValidator normalization', async () => { const workflow = { nodes: [ { id: '1', name: 'Node@#$%', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'Node 中文', type: 'n8n-nodes-base.set', position: [100, 0] as [number, number], parameters: {} }, { id: '3', name: 'Node😊', type: 'n8n-nodes-base.set', position: [200, 0] as [number, number], parameters: {} } ], connections: { 'Node@#$%': { main: [[{ node: 'Node 中文', type: 'main', index: 0 }]] }, 'Node 中文': { main: [[{ node: 'Node😊', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); it('should handle very long node names', async () => { const longName = 'A'.repeat(1000); const workflow = { nodes: [ { id: '1', name: longName, type: 'test.node', position: [0, 0] as [number, number], parameters: {} } ], connections: {} }; const result = await validator.validateWorkflow(workflow as any); expect(result.warnings.some(w => w.message.includes('very long'))).toBe(true); }); }); describe('Batch Validation', () => { it.skip('should handle batch validation with mixed valid/invalid workflows - FIXME: mock issues', async () => { const workflows = [ { nodes: [ { id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [100, 0] as [number, number], parameters: {} } ], connections: { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: 0 }]] } } }, null as any, { nodes: 'invalid' as any, connections: {} } ]; const promises = workflows.map(w => validator.validateWorkflow(w)); const results = await Promise.all(promises); expect(results[0].valid).toBe(true); expect(results[1].valid).toBe(false); expect(results[2].valid).toBe(false); }); it.skip('should handle concurrent validation requests - FIXME: mock issues', async () => { const workflow = { nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: {} }], connections: {} }; const promises = Array(10).fill(null).map(() => validator.validateWorkflow(workflow)); const results = await Promise.all(promises); expect(results.every(r => r.valid)).toBe(true); }); }); describe('Expression Validation Edge Cases', () => { it('should skip expression validation when option is false', async () => { const workflow = { nodes: [{ id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: { value: '{{ $json.invalid.expression }}' } }], connections: {} }; const result = await validator.validateWorkflow(workflow, { validateExpressions: false }); expect(result.statistics.expressionsValidated).toBe(0); }); }); describe('Connection Type Validation', () => { it('should validate different connection types', async () => { const workflow = { nodes: [ { id: '1', name: 'Agent', type: 'test.agent', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'Tool', type: 'test.tool', position: [100, 0] as [number, number], parameters: {} } ], connections: { 'Tool': { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.statistics.validConnections).toBeGreaterThan(0); }); }); describe('Error Recovery', () => { it('should continue validation after encountering errors', async () => { const workflow = { nodes: [ { id: '1', name: null as any, type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'Valid', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }, { id: '3', name: 'AlsoValid', type: 'test.node', position: [200, 0] as [number, number], parameters: {} } ], connections: { 'Valid': { main: [[{ node: 'AlsoValid', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow as any); expect(result.errors.length).toBeGreaterThan(0); expect(result.statistics.validConnections).toBeGreaterThan(0); }); }); describe('Static Method Alternatives', () => { it('should validate workflow connections only', async () => { const workflow = { nodes: [ { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }, { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} } ], connections: { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: 0 }]] } } }; const result = await validator.validateWorkflow(workflow, { validateNodes: false, validateExpressions: false, validateConnections: true }); expect(result.statistics.validConnections).toBe(1); }); it('should validate workflow expressions only', async () => { const workflow = { nodes: [{ id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: { value: '{{ $json.data }}' } }], connections: {} }; const result = await validator.validateWorkflow(workflow, { validateNodes: false, validateExpressions: true, validateConnections: false }); expect(result.statistics.expressionsValidated).toBeGreaterThan(0); }); }); }); ``` -------------------------------------------------------------------------------- /src/services/ai-node-validator.ts: -------------------------------------------------------------------------------- ```typescript /** * AI Node Validator * * Implements validation logic for AI Agent, Chat Trigger, and Basic LLM Chain nodes * from docs/FINAL_AI_VALIDATION_SPEC.md * * Key Features: * - Reverse connection mapping (AI connections flow TO the consumer) * - AI Agent comprehensive validation (prompt types, fallback models, streaming mode) * - Chat Trigger validation (streaming mode constraints) * - Integration with AI tool validators */ import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; import { WorkflowNode, WorkflowJson, ReverseConnection, ValidationIssue, isAIToolSubNode, validateAIToolSubNode } from './ai-tool-validators'; // Re-export types for test files export type { WorkflowNode, WorkflowJson, ReverseConnection, ValidationIssue } from './ai-tool-validators'; // Validation constants const MIN_SYSTEM_MESSAGE_LENGTH = 20; const MAX_ITERATIONS_WARNING_THRESHOLD = 50; /** * AI Connection Types * From spec lines 551-596 */ export const AI_CONNECTION_TYPES = [ 'ai_languageModel', 'ai_memory', 'ai_tool', 'ai_embedding', 'ai_vectorStore', 'ai_document', 'ai_textSplitter', 'ai_outputParser' ] as const; /** * Build Reverse Connection Map * * CRITICAL: AI connections flow TO the consumer node (reversed from standard n8n pattern) * This utility maps which nodes connect TO each node, essential for AI validation. * * From spec lines 551-596 * * @example * Standard n8n: [Source] --main--> [Target] * workflow.connections["Source"]["main"] = [[{node: "Target", ...}]] * * AI pattern: [Language Model] --ai_languageModel--> [AI Agent] * workflow.connections["Language Model"]["ai_languageModel"] = [[{node: "AI Agent", ...}]] * * Reverse map: reverseMap.get("AI Agent") = [{sourceName: "Language Model", type: "ai_languageModel", ...}] */ export function buildReverseConnectionMap( workflow: WorkflowJson ): Map<string, ReverseConnection[]> { const map = new Map<string, ReverseConnection[]>(); // Iterate through all connections for (const [sourceName, outputs] of Object.entries(workflow.connections)) { // Validate source name is not empty if (!sourceName || typeof sourceName !== 'string' || sourceName.trim() === '') { continue; } if (!outputs || typeof outputs !== 'object') continue; // Iterate through all output types (main, error, ai_tool, ai_languageModel, etc.) for (const [outputType, connections] of Object.entries(outputs)) { if (!Array.isArray(connections)) continue; // Flatten nested arrays and process each connection const connArray = connections.flat().filter(c => c); for (const conn of connArray) { if (!conn || !conn.node) continue; // Validate target node name is not empty if (typeof conn.node !== 'string' || conn.node.trim() === '') { continue; } // Initialize array for target node if not exists if (!map.has(conn.node)) { map.set(conn.node, []); } // Add reverse connection entry map.get(conn.node)!.push({ sourceName: sourceName, sourceType: outputType, type: outputType, index: conn.index ?? 0 }); } } } return map; } /** * Get AI connections TO a specific node */ export function getAIConnections( nodeName: string, reverseConnections: Map<string, ReverseConnection[]>, connectionType?: string ): ReverseConnection[] { const incoming = reverseConnections.get(nodeName) || []; if (connectionType) { return incoming.filter(c => c.type === connectionType); } return incoming.filter(c => AI_CONNECTION_TYPES.includes(c.type as any)); } /** * Validate AI Agent Node * From spec lines 3-549 * * Validates: * - Language model connections (1 or 2 if fallback) * - Output parser connection + hasOutputParser flag * - Prompt type configuration (auto vs define) * - System message recommendations * - Streaming mode constraints (CRITICAL) * - Memory connections (0-1) * - Tool connections * - maxIterations validation */ export function validateAIAgent( node: WorkflowNode, reverseConnections: Map<string, ReverseConnection[]>, workflow: WorkflowJson ): ValidationIssue[] { const issues: ValidationIssue[] = []; const incoming = reverseConnections.get(node.name) || []; // 1. Validate language model connections (REQUIRED: 1 or 2 if fallback) const languageModelConnections = incoming.filter(c => c.type === 'ai_languageModel'); if (languageModelConnections.length === 0) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" requires an ai_languageModel connection. Connect a language model node (e.g., OpenAI Chat Model, Anthropic Chat Model).`, code: 'MISSING_LANGUAGE_MODEL' }); } else if (languageModelConnections.length > 2) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has ${languageModelConnections.length} ai_languageModel connections. Maximum is 2 (for fallback model support).`, code: 'TOO_MANY_LANGUAGE_MODELS' }); } else if (languageModelConnections.length === 2) { // Check if fallback is enabled if (!node.parameters.needsFallback) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has 2 language models but needsFallback is not enabled. Set needsFallback=true or remove the second model.` }); } } else if (languageModelConnections.length === 1 && node.parameters.needsFallback === true) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has needsFallback=true but only 1 language model connected. Connect a second model for fallback or disable needsFallback.`, code: 'FALLBACK_MISSING_SECOND_MODEL' }); } // 2. Validate output parser configuration const outputParserConnections = incoming.filter(c => c.type === 'ai_outputParser'); if (node.parameters.hasOutputParser === true) { if (outputParserConnections.length === 0) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has hasOutputParser=true but no ai_outputParser connection. Connect an output parser or set hasOutputParser=false.`, code: 'MISSING_OUTPUT_PARSER' }); } } else if (outputParserConnections.length > 0) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has an output parser connected but hasOutputParser is not true. Set hasOutputParser=true to enable output parsing.` }); } if (outputParserConnections.length > 1) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has ${outputParserConnections.length} output parsers. Only 1 is allowed.`, code: 'MULTIPLE_OUTPUT_PARSERS' }); } // 3. Validate prompt type configuration if (node.parameters.promptType === 'define') { if (!node.parameters.text || node.parameters.text.trim() === '') { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has promptType="define" but the text field is empty. Provide a custom prompt or switch to promptType="auto".`, code: 'MISSING_PROMPT_TEXT' }); } } // 4. Check system message (RECOMMENDED) if (!node.parameters.systemMessage) { issues.push({ severity: 'info', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has no systemMessage. Consider adding one to define the agent's role, capabilities, and constraints.` }); } else if (node.parameters.systemMessage.trim().length < MIN_SYSTEM_MESSAGE_LENGTH) { issues.push({ severity: 'info', nodeId: node.id, nodeName: node.name, 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.` }); } // 5. Validate streaming mode constraints (CRITICAL) // From spec lines 753-879: AI Agent with streaming MUST NOT have main output connections const isStreamingTarget = checkIfStreamingTarget(node, workflow, reverseConnections); const hasOwnStreamingEnabled = node.parameters?.options?.streamResponse === true; if (isStreamingTarget || hasOwnStreamingEnabled) { // Check if AI Agent has any main output connections const agentMainOutput = workflow.connections[node.name]?.main; if (agentMainOutput && agentMainOutput.flat().some((c: any) => c)) { const streamSource = isStreamingTarget ? 'connected from Chat Trigger with responseMode="streaming"' : 'has streamResponse=true in options'; issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, 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.`, code: 'STREAMING_WITH_MAIN_OUTPUT' }); } } // 6. Validate memory connections (0-1 allowed) const memoryConnections = incoming.filter(c => c.type === 'ai_memory'); if (memoryConnections.length > 1) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has ${memoryConnections.length} ai_memory connections. Only 1 memory is allowed.`, code: 'MULTIPLE_MEMORY_CONNECTIONS' }); } // 7. Validate tool connections const toolConnections = incoming.filter(c => c.type === 'ai_tool'); if (toolConnections.length === 0) { issues.push({ severity: 'info', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has no ai_tool connections. Consider adding tools to enhance the agent's capabilities.` }); } // 8. Validate maxIterations if specified if (node.parameters.maxIterations !== undefined) { if (typeof node.parameters.maxIterations !== 'number') { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has invalid maxIterations type. Must be a number.`, code: 'INVALID_MAX_ITERATIONS_TYPE' }); } else if (node.parameters.maxIterations < 1) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `AI Agent "${node.name}" has maxIterations=${node.parameters.maxIterations}. Must be at least 1.`, code: 'MAX_ITERATIONS_TOO_LOW' }); } else if (node.parameters.maxIterations > MAX_ITERATIONS_WARNING_THRESHOLD) { issues.push({ severity: 'warning', nodeId: node.id, nodeName: node.name, 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.` }); } } return issues; } /** * Check if AI Agent is a streaming target * Helper function to determine if an AI Agent is receiving streaming input from Chat Trigger */ function checkIfStreamingTarget( node: WorkflowNode, workflow: WorkflowJson, reverseConnections: Map<string, ReverseConnection[]> ): boolean { const incoming = reverseConnections.get(node.name) || []; // Check if any incoming main connection is from a Chat Trigger with streaming enabled const mainConnections = incoming.filter(c => c.type === 'main'); for (const conn of mainConnections) { const sourceNode = workflow.nodes.find(n => n.name === conn.sourceName); if (!sourceNode) continue; const normalizedType = NodeTypeNormalizer.normalizeToFullForm(sourceNode.type); if (normalizedType === 'nodes-langchain.chatTrigger') { const responseMode = sourceNode.parameters?.options?.responseMode || 'lastNode'; if (responseMode === 'streaming') { return true; } } } return false; } /** * Validate Chat Trigger Node * From spec lines 753-879 * * Critical validations: * - responseMode="streaming" requires AI Agent target * - AI Agent with streaming MUST NOT have main output connections * - responseMode="lastNode" validation */ export function validateChatTrigger( node: WorkflowNode, workflow: WorkflowJson, reverseConnections: Map<string, ReverseConnection[]> ): ValidationIssue[] { const issues: ValidationIssue[] = []; const responseMode = node.parameters?.options?.responseMode || 'lastNode'; // Get outgoing main connections from Chat Trigger const outgoingMain = workflow.connections[node.name]?.main; if (!outgoingMain || outgoingMain.length === 0 || !outgoingMain[0] || outgoingMain[0].length === 0) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Chat Trigger "${node.name}" has no outgoing connections. Connect it to an AI Agent or workflow.`, code: 'MISSING_CONNECTIONS' }); return issues; } const firstConnection = outgoingMain[0][0]; if (!firstConnection) { return issues; } const targetNode = workflow.nodes.find(n => n.name === firstConnection.node); if (!targetNode) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Chat Trigger "${node.name}" connects to non-existent node "${firstConnection.node}".`, code: 'INVALID_TARGET_NODE' }); return issues; } const targetType = NodeTypeNormalizer.normalizeToFullForm(targetNode.type); // Validate streaming mode if (responseMode === 'streaming') { // CRITICAL: Streaming mode only works with AI Agent if (targetType !== 'nodes-langchain.agent') { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, 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.`, code: 'STREAMING_WRONG_TARGET' }); } else { // CRITICAL: Check AI Agent has NO main output connections const agentMainOutput = workflow.connections[targetNode.name]?.main; if (agentMainOutput && agentMainOutput.flat().some((c: any) => c)) { issues.push({ severity: 'error', nodeId: targetNode.id, nodeName: targetNode.name, 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.`, code: 'STREAMING_AGENT_HAS_OUTPUT' }); } } } // Validate lastNode mode if (responseMode === 'lastNode') { // lastNode mode requires a workflow that ends somewhere // Just informational - this is the default and works with any workflow if (targetType === 'nodes-langchain.agent') { issues.push({ severity: 'info', nodeId: node.id, nodeName: node.name, message: `Chat Trigger "${node.name}" uses responseMode="lastNode" with AI Agent. Consider using responseMode="streaming" for better user experience with real-time responses.` }); } } return issues; } /** * Validate Basic LLM Chain Node * From spec - simplified AI chain without agent loop * * Similar to AI Agent but simpler: * - Requires exactly 1 language model * - Can have 0-1 memory * - No tools (not an agent) * - No fallback model support */ export function validateBasicLLMChain( node: WorkflowNode, reverseConnections: Map<string, ReverseConnection[]> ): ValidationIssue[] { const issues: ValidationIssue[] = []; const incoming = reverseConnections.get(node.name) || []; // 1. Validate language model connection (REQUIRED: exactly 1) const languageModelConnections = incoming.filter(c => c.type === 'ai_languageModel'); if (languageModelConnections.length === 0) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Basic LLM Chain "${node.name}" requires an ai_languageModel connection. Connect a language model node.`, code: 'MISSING_LANGUAGE_MODEL' }); } else if (languageModelConnections.length > 1) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Basic LLM Chain "${node.name}" has ${languageModelConnections.length} ai_languageModel connections. Basic LLM Chain only supports 1 language model (no fallback).`, code: 'MULTIPLE_LANGUAGE_MODELS' }); } // 2. Validate memory connections (0-1 allowed) const memoryConnections = incoming.filter(c => c.type === 'ai_memory'); if (memoryConnections.length > 1) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Basic LLM Chain "${node.name}" has ${memoryConnections.length} ai_memory connections. Only 1 memory is allowed.`, code: 'MULTIPLE_MEMORY_CONNECTIONS' }); } // 3. Check for tool connections (not supported) const toolConnections = incoming.filter(c => c.type === 'ai_tool'); if (toolConnections.length > 0) { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, 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.`, code: 'TOOLS_NOT_SUPPORTED' }); } // 4. Validate prompt configuration if (node.parameters.promptType === 'define') { if (!node.parameters.text || node.parameters.text.trim() === '') { issues.push({ severity: 'error', nodeId: node.id, nodeName: node.name, message: `Basic LLM Chain "${node.name}" has promptType="define" but the text field is empty.`, code: 'MISSING_PROMPT_TEXT' }); } } return issues; } /** * Validate all AI-specific nodes in a workflow * * This is the main entry point called by WorkflowValidator */ export function validateAISpecificNodes( workflow: WorkflowJson ): ValidationIssue[] { const issues: ValidationIssue[] = []; // Build reverse connection map (critical for AI validation) const reverseConnectionMap = buildReverseConnectionMap(workflow); for (const node of workflow.nodes) { if (node.disabled) continue; const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type); // Validate AI Agent nodes if (normalizedType === 'nodes-langchain.agent') { const nodeIssues = validateAIAgent(node, reverseConnectionMap, workflow); issues.push(...nodeIssues); } // Validate Chat Trigger nodes if (normalizedType === 'nodes-langchain.chatTrigger') { const nodeIssues = validateChatTrigger(node, workflow, reverseConnectionMap); issues.push(...nodeIssues); } // Validate Basic LLM Chain nodes if (normalizedType === 'nodes-langchain.chainLlm') { const nodeIssues = validateBasicLLMChain(node, reverseConnectionMap); issues.push(...nodeIssues); } // Validate AI tool sub-nodes (13 types) if (isAIToolSubNode(normalizedType)) { const nodeIssues = validateAIToolSubNode( node, normalizedType, reverseConnectionMap, workflow ); issues.push(...nodeIssues); } } return issues; } /** * Check if a workflow contains any AI nodes * Useful for skipping AI validation when not needed */ export function hasAINodes(workflow: WorkflowJson): boolean { const aiNodeTypes = [ 'nodes-langchain.agent', 'nodes-langchain.chatTrigger', 'nodes-langchain.chainLlm', ]; return workflow.nodes.some(node => { const normalized = NodeTypeNormalizer.normalizeToFullForm(node.type); return aiNodeTypes.includes(normalized) || isAIToolSubNode(normalized); }); } /** * Helper: Get AI node type category */ export function getAINodeCategory(nodeType: string): string | null { const normalized = NodeTypeNormalizer.normalizeToFullForm(nodeType); if (normalized === 'nodes-langchain.agent') return 'AI Agent'; if (normalized === 'nodes-langchain.chatTrigger') return 'Chat Trigger'; if (normalized === 'nodes-langchain.chainLlm') return 'Basic LLM Chain'; if (isAIToolSubNode(normalized)) return 'AI Tool'; // Check for AI component nodes if (normalized.startsWith('nodes-langchain.')) { if (normalized.includes('openAi') || normalized.includes('anthropic') || normalized.includes('googleGemini')) { return 'Language Model'; } if (normalized.includes('memory') || normalized.includes('buffer')) { return 'Memory'; } if (normalized.includes('vectorStore') || normalized.includes('pinecone') || normalized.includes('qdrant')) { return 'Vector Store'; } if (normalized.includes('embedding')) { return 'Embeddings'; } return 'AI Component'; } return null; } ``` -------------------------------------------------------------------------------- /tests/unit/parsers/simple-parser.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { SimpleParser } from '@/parsers/simple-parser'; import { programmaticNodeFactory, declarativeNodeFactory, triggerNodeFactory, webhookNodeFactory, aiToolNodeFactory, versionedNodeClassFactory, versionedNodeTypeClassFactory, malformedNodeFactory, nodeClassFactory, propertyFactory, stringPropertyFactory, resourcePropertyFactory, operationPropertyFactory } from '@tests/fixtures/factories/parser-node.factory'; describe('SimpleParser', () => { let parser: SimpleParser; beforeEach(() => { parser = new SimpleParser(); }); describe('parse method', () => { it('should parse a basic programmatic node', () => { const nodeDefinition = programmaticNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result).toMatchObject({ style: 'programmatic', nodeType: nodeDefinition.name, displayName: nodeDefinition.displayName, description: nodeDefinition.description, category: nodeDefinition.group?.[0], properties: nodeDefinition.properties, credentials: nodeDefinition.credentials || [], isAITool: false, isWebhook: false, version: nodeDefinition.version?.toString() || '1', isVersioned: false, isTrigger: false, operations: expect.any(Array) }); }); it('should parse a declarative node', () => { const nodeDefinition = declarativeNodeFactory.build(); // Fix the routing structure for simple parser - it expects operation.options to be an array nodeDefinition.routing.request!.operation = { options: [ { name: 'Create User', value: 'createUser' }, { name: 'Get User', value: 'getUser' } ] } as any; const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.style).toBe('declarative'); expect(result.operations.length).toBeGreaterThan(0); }); it('should detect trigger nodes', () => { const nodeDefinition = triggerNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.isTrigger).toBe(true); }); it('should detect webhook nodes', () => { const nodeDefinition = webhookNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.isWebhook).toBe(true); }); it('should detect AI tool nodes', () => { const nodeDefinition = aiToolNodeFactory.build(); // Fix the routing structure for simple parser nodeDefinition.routing.request!.operation = { options: [ { name: 'Create', value: 'create' } ] } as any; const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.isAITool).toBe(true); }); it('should parse VersionedNodeType class', () => { const versionedDef = versionedNodeClassFactory.build(); const VersionedNodeClass = class VersionedNodeType { baseDescription = versionedDef.baseDescription; nodeVersions = versionedDef.nodeVersions; currentVersion = versionedDef.baseDescription!.defaultVersion; constructor() { Object.defineProperty(this.constructor, 'name', { value: 'VersionedNodeType', configurable: true }); } }; const result = parser.parse(VersionedNodeClass as any); expect(result.isVersioned).toBe(true); expect(result.nodeType).toBe(versionedDef.baseDescription!.name); expect(result.displayName).toBe(versionedDef.baseDescription!.displayName); expect(result.version).toBe(versionedDef.baseDescription!.defaultVersion.toString()); }); it('should merge baseDescription with version-specific description', () => { const VersionedNodeClass = class VersionedNodeType { baseDescription = { name: 'mergedNode', displayName: 'Base Display Name', description: 'Base description' }; nodeVersions = { 1: { description: { displayName: 'Version 1 Display Name', properties: [propertyFactory.build()] } } }; currentVersion = 1; constructor() { Object.defineProperty(this.constructor, 'name', { value: 'VersionedNodeType', configurable: true }); } }; const result = parser.parse(VersionedNodeClass as any); // Should merge baseDescription with version description expect(result.nodeType).toBe('mergedNode'); // From base expect(result.displayName).toBe('Version 1 Display Name'); // From version (overrides base) expect(result.description).toBe('Base description'); // From base }); it('should throw error for nodes without name', () => { const nodeDefinition = malformedNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); expect(() => parser.parse(NodeClass as any)).toThrow('Node is missing name property'); }); it('should handle nodes that fail to instantiate', () => { const NodeClass = class { constructor() { throw new Error('Cannot instantiate'); } }; expect(() => parser.parse(NodeClass as any)).toThrow('Node is missing name property'); }); it('should handle static description property', () => { const nodeDefinition = programmaticNodeFactory.build(); const NodeClass = class { static description = nodeDefinition; }; // Since it can't instantiate and has no static description accessible, // it should throw for missing name expect(() => parser.parse(NodeClass as any)).toThrow(); }); it('should handle instance-based nodes', () => { const nodeDefinition = programmaticNodeFactory.build(); const nodeInstance = { description: nodeDefinition }; const result = parser.parse(nodeInstance as any); expect(result.displayName).toBe(nodeDefinition.displayName); }); it('should use displayName fallback to name if not provided', () => { const nodeDefinition = programmaticNodeFactory.build(); delete (nodeDefinition as any).displayName; const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.displayName).toBe(nodeDefinition.name); }); it('should handle category extraction from different fields', () => { const testCases = [ { description: { group: ['transform'], categories: ['output'] }, expected: 'transform' // group takes precedence }, { description: { categories: ['output'] }, expected: 'output' }, { description: {}, expected: undefined } ]; testCases.forEach(({ description, expected }) => { const baseDefinition = programmaticNodeFactory.build(); // Remove any existing group/categories from base definition to avoid conflicts delete baseDefinition.group; delete baseDefinition.categories; const nodeDefinition = { ...baseDefinition, ...description, name: baseDefinition.name // Ensure name is preserved }; const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.category).toBe(expected); }); }); }); describe('trigger detection', () => { it('should detect triggers by group', () => { const nodeDefinition = programmaticNodeFactory.build({ group: ['trigger'] }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.isTrigger).toBe(true); }); it('should detect polling triggers', () => { const nodeDefinition = programmaticNodeFactory.build({ polling: true }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.isTrigger).toBe(true); }); it('should detect trigger property', () => { const nodeDefinition = programmaticNodeFactory.build({ trigger: true }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.isTrigger).toBe(true); }); it('should detect event triggers', () => { const nodeDefinition = programmaticNodeFactory.build({ eventTrigger: true }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.isTrigger).toBe(true); }); it('should detect triggers by name', () => { const nodeDefinition = programmaticNodeFactory.build({ name: 'customTrigger' }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.isTrigger).toBe(true); }); }); describe('operations extraction', () => { it('should extract declarative operations from routing.request', () => { const nodeDefinition = declarativeNodeFactory.build(); // Fix the routing structure for simple parser nodeDefinition.routing.request!.operation = { options: [ { name: 'Create', value: 'create' }, { name: 'Get', value: 'get' } ] as any }; const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); // Should have resource operations const resourceOps = result.operations.filter(op => op.resource); expect(resourceOps.length).toBeGreaterThan(0); // Should have operation entries const operationOps = result.operations.filter(op => op.operation && !op.resource); expect(operationOps.length).toBeGreaterThan(0); }); it('should extract declarative operations from routing.operations', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', routing: { operations: { create: { displayName: 'Create Item' }, read: { displayName: 'Read Item' }, update: { displayName: 'Update Item' }, delete: { displayName: 'Delete Item' } } } } }); const result = parser.parse(NodeClass as any); expect(result.operations).toHaveLength(4); expect(result.operations).toEqual(expect.arrayContaining([ { operation: 'create', name: 'Create Item' }, { operation: 'read', name: 'Read Item' }, { operation: 'update', name: 'Update Item' }, { operation: 'delete', name: 'Delete Item' } ])); }); it('should extract programmatic operations from resource property', () => { const resourceProp = resourcePropertyFactory.build(); const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [resourceProp] } }); const result = parser.parse(NodeClass as any); const resourceOps = result.operations.filter(op => op.type === 'resource'); expect(resourceOps).toHaveLength(resourceProp.options!.length); resourceOps.forEach((op, idx) => { expect(op).toMatchObject({ type: 'resource', resource: resourceProp.options![idx].value, name: resourceProp.options![idx].name }); }); }); it('should extract programmatic operations with resource context', () => { const operationProp = operationPropertyFactory.build(); const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [operationProp] } }); const result = parser.parse(NodeClass as any); const operationOps = result.operations.filter(op => op.type === 'operation'); expect(operationOps).toHaveLength(operationProp.options!.length); // Should extract resource context from displayOptions expect(operationOps[0].resources).toEqual(['user']); }); it('should handle operations with multiple resource conditions', () => { const operationProp = { name: 'operation', type: 'options', displayOptions: { show: { resource: ['user', 'post', 'comment'] } }, options: [ { name: 'Create', value: 'create', action: 'Create item' } ] }; const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [operationProp] } }); const result = parser.parse(NodeClass as any); const operationOps = result.operations.filter(op => op.type === 'operation'); expect(operationOps[0].resources).toEqual(['user', 'post', 'comment']); }); it('should handle single resource condition as array', () => { const operationProp = { name: 'operation', type: 'options', displayOptions: { show: { resource: 'user' // Single value, not array } }, options: [ { name: 'Get', value: 'get' } ] }; const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [operationProp] } }); const result = parser.parse(NodeClass as any); const operationOps = result.operations.filter(op => op.type === 'operation'); expect(operationOps[0].resources).toEqual(['user']); }); }); describe('version extraction', () => { it('should prioritize currentVersion over description.defaultVersion', () => { const NodeClass = class { currentVersion = 2.2; // Should be returned description = { name: 'test', displayName: 'Test', defaultVersion: 3 // Should be ignored when currentVersion exists }; }; const result = parser.parse(NodeClass as any); expect(result.version).toBe('2.2'); }); it('should extract version from description.defaultVersion', () => { const NodeClass = class { description = { name: 'test', displayName: 'Test', defaultVersion: 3 }; }; const result = parser.parse(NodeClass as any); expect(result.version).toBe('3'); }); it('should NOT extract version from non-existent baseDescription (legacy bug)', () => { // This test verifies the bug fix from v2.17.4 // baseDescription.defaultVersion doesn't exist on VersionedNodeType instances const NodeClass = class { baseDescription = { // This property doesn't exist on VersionedNodeType! name: 'test', displayName: 'Test', defaultVersion: 3 }; // Constructor name trick to detect as VersionedNodeType constructor() { Object.defineProperty(this.constructor, 'name', { value: 'VersionedNodeType', configurable: true }); } }; const result = parser.parse(NodeClass as any); // Should fallback to default version '1' since baseDescription.defaultVersion doesn't exist expect(result.version).toBe('1'); }); it('should extract version from description.version', () => { // For this test, the version needs to be in the instantiated description const NodeClass = class { description = { name: 'test', version: 2 }; }; const result = parser.parse(NodeClass as any); expect(result.version).toBe('2'); }); it('should default to version 1', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test' } }); const result = parser.parse(NodeClass as any); expect(result.version).toBe('1'); }); }); describe('versioned node detection', () => { it('should detect nodes with baseDescription and nodeVersions', () => { // For simple parser, need to create a proper class structure const NodeClass = class { baseDescription = { name: 'test', displayName: 'Test' }; nodeVersions = { 1: {}, 2: {} }; constructor() { Object.defineProperty(this.constructor, 'name', { value: 'VersionedNodeType', configurable: true }); } }; const result = parser.parse(NodeClass as any); expect(result.isVersioned).toBe(true); }); it('should detect nodes with version array', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', version: [1, 1.1, 2] } }); const result = parser.parse(NodeClass as any); expect(result.isVersioned).toBe(true); }); it('should detect nodes with defaultVersion', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', defaultVersion: 2 } }); const result = parser.parse(NodeClass as any); expect(result.isVersioned).toBe(true); }); it('should handle instance-level version detection', () => { const NodeClass = class { description = { name: 'test', version: [1, 2, 3] }; }; const result = parser.parse(NodeClass as any); expect(result.isVersioned).toBe(true); }); }); describe('edge cases', () => { it('should handle empty routing object', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', routing: {} } }); const result = parser.parse(NodeClass as any); expect(result.style).toBe('declarative'); expect(result.operations).toEqual([]); }); it('should handle missing properties array', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test' } }); const result = parser.parse(NodeClass as any); expect(result.properties).toEqual([]); }); it('should handle missing credentials', () => { const nodeDefinition = programmaticNodeFactory.build(); delete (nodeDefinition as any).credentials; const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any); expect(result.credentials).toEqual([]); }); it('should handle nodes with baseDescription but no name in main description', () => { const NodeClass = class { description = {}; baseDescription = { name: 'baseNode', displayName: 'Base Node' }; }; const result = parser.parse(NodeClass as any); expect(result.nodeType).toBe('baseNode'); expect(result.displayName).toBe('Base Node'); }); it('should handle complex nested routing structures', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', routing: { request: { resource: { options: [] }, operation: { options: [] // Should be array, not object } }, operations: {} } } }); const result = parser.parse(NodeClass as any); expect(result.operations).toEqual([]); }); it('should handle operations without displayName', () => { const NodeClass = nodeClassFactory.build({ description: { name: 'test', properties: [ { name: 'operation', type: 'options', displayOptions: { show: {} }, options: [ { value: 'create' }, // No name field { value: 'update', name: 'Update' } ] } ] } }); const result = parser.parse(NodeClass as any); // Should handle missing names gracefully expect(result.operations).toHaveLength(2); }); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/performance.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { TestableN8NMCPServer } from './test-helpers'; describe('MCP Performance Tests', () => { let mcpServer: TestableN8NMCPServer; let client: Client; beforeEach(async () => { mcpServer = new TestableN8NMCPServer(); await mcpServer.initialize(); const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); await mcpServer.connectToTransport(serverTransport); client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); await client.connect(clientTransport); // Verify database is populated by checking statistics const statsResponse = await client.callTool({ name: 'get_database_statistics', arguments: {} }); if ((statsResponse as any).content && (statsResponse as any).content[0]) { const stats = JSON.parse((statsResponse as any).content[0].text); // Ensure database has nodes for testing if (!stats.totalNodes || stats.totalNodes === 0) { console.error('Database stats:', stats); throw new Error('Test database not properly populated'); } } }); afterEach(async () => { await client.close(); await mcpServer.close(); }); describe('Response Time Benchmarks', () => { it('should respond to simple queries quickly', async () => { const iterations = 100; const start = performance.now(); for (let i = 0; i < iterations; i++) { await client.callTool({ name: 'get_database_statistics', arguments: {} }); } const duration = performance.now() - start; const avgTime = duration / iterations; console.log(`Average response time for get_database_statistics: ${avgTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Environment-aware threshold (relaxed +20% for type safety overhead) const threshold = process.env.CI ? 20 : 12; expect(avgTime).toBeLessThan(threshold); }); it('should handle list operations efficiently', async () => { const iterations = 50; const start = performance.now(); for (let i = 0; i < iterations; i++) { await client.callTool({ name: 'list_nodes', arguments: { limit: 10 } }); } const duration = performance.now() - start; const avgTime = duration / iterations; console.log(`Average response time for list_nodes: ${avgTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Environment-aware threshold const threshold = process.env.CI ? 40 : 20; expect(avgTime).toBeLessThan(threshold); }); it('should perform searches efficiently', async () => { const searches = ['http', 'webhook', 'slack', 'database', 'api']; const iterations = 20; const start = performance.now(); for (let i = 0; i < iterations; i++) { for (const query of searches) { await client.callTool({ name: 'search_nodes', arguments: { query } }); } } const totalRequests = iterations * searches.length; const duration = performance.now() - start; const avgTime = duration / totalRequests; console.log(`Average response time for search_nodes: ${avgTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Environment-aware threshold const threshold = process.env.CI ? 60 : 30; expect(avgTime).toBeLessThan(threshold); }); it('should retrieve node info quickly', async () => { const nodeTypes = [ 'nodes-base.httpRequest', 'nodes-base.webhook', 'nodes-base.set', 'nodes-base.if', 'nodes-base.switch' ]; const start = performance.now(); for (const nodeType of nodeTypes) { await client.callTool({ name: 'get_node_info', arguments: { nodeType } }); } const duration = performance.now() - start; const avgTime = duration / nodeTypes.length; console.log(`Average response time for get_node_info: ${avgTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Environment-aware threshold (these are large responses) const threshold = process.env.CI ? 100 : 50; expect(avgTime).toBeLessThan(threshold); }); }); describe('Concurrent Request Performance', () => { it('should handle concurrent requests efficiently', async () => { const concurrentRequests = 50; const start = performance.now(); const promises = []; for (let i = 0; i < concurrentRequests; i++) { promises.push( client.callTool({ name: 'list_nodes', arguments: { limit: 5 } }) ); } await Promise.all(promises); const duration = performance.now() - start; const avgTime = duration / concurrentRequests; console.log(`Average time for ${concurrentRequests} concurrent requests: ${avgTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Concurrent requests should be more efficient than sequential const threshold = process.env.CI ? 25 : 10; expect(avgTime).toBeLessThan(threshold); }); it('should handle mixed concurrent operations', async () => { const operations = [ { tool: 'list_nodes', params: { limit: 10 } }, { tool: 'search_nodes', params: { query: 'http' } }, { tool: 'get_database_statistics', params: {} }, { tool: 'list_ai_tools', params: {} }, { tool: 'list_tasks', params: {} } ]; const rounds = 10; const start = performance.now(); for (let round = 0; round < rounds; round++) { const promises = operations.map(op => client.callTool({ name: op.tool, arguments: op.params }) ); await Promise.all(promises); } const duration = performance.now() - start; const totalRequests = rounds * operations.length; const avgTime = duration / totalRequests; console.log(`Average time for mixed operations: ${avgTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); const threshold = process.env.CI ? 40 : 20; expect(avgTime).toBeLessThan(threshold); }); }); describe('Large Data Performance', () => { it('should handle large node lists efficiently', async () => { const start = performance.now(); const response = await client.callTool({ name: 'list_nodes', arguments: { limit: 200 // Get many nodes } }); const duration = performance.now() - start; console.log(`Time to list 200 nodes: ${duration.toFixed(2)}ms`); // Environment-aware threshold const threshold = process.env.CI ? 200 : 100; expect(duration).toBeLessThan(threshold); // Check the response content expect(response).toBeDefined(); let nodes; if (response.content && Array.isArray(response.content) && response.content[0]) { // MCP standard response format expect(response.content[0].type).toBe('text'); expect(response.content[0].text).toBeDefined(); try { const parsed = JSON.parse(response.content[0].text); // list_nodes returns an object with nodes property nodes = parsed.nodes || parsed; } catch (e) { console.error('Failed to parse JSON:', e); console.error('Response text was:', response.content[0].text); throw e; } } else if (Array.isArray(response)) { // Direct array response nodes = response; } else if (response.nodes) { // Object with nodes property nodes = response.nodes; } else { console.error('Unexpected response format:', response); throw new Error('Unexpected response format'); } expect(nodes).toBeDefined(); expect(Array.isArray(nodes)).toBe(true); expect(nodes.length).toBeGreaterThan(100); }); it('should handle large workflow validation efficiently', async () => { // Create a large workflow const nodeCount = 100; const nodes = []; const connections: any = {}; for (let i = 0; i < nodeCount; i++) { nodes.push({ id: String(i), name: `Node${i}`, type: i % 3 === 0 ? 'nodes-base.httpRequest' : 'nodes-base.set', typeVersion: 1, position: [i * 100, 0], parameters: i % 3 === 0 ? { method: 'GET', url: 'https://api.example.com' } : { values: { string: [{ name: 'test', value: 'value' }] } } }); if (i > 0) { connections[`Node${i-1}`] = { 'main': [[{ node: `Node${i}`, type: 'main', index: 0 }]] }; } } const start = performance.now(); const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow: { nodes, connections } } }); const duration = performance.now() - start; console.log(`Time to validate ${nodeCount} node workflow: ${duration.toFixed(2)}ms`); // Environment-aware threshold const threshold = process.env.CI ? 1000 : 500; expect(duration).toBeLessThan(threshold); // Check the response content - MCP callTool returns content array with text expect(response).toBeDefined(); expect((response as any).content).toBeDefined(); expect(Array.isArray((response as any).content)).toBe(true); expect((response as any).content.length).toBeGreaterThan(0); expect((response as any).content[0]).toBeDefined(); expect((response as any).content[0].type).toBe('text'); expect((response as any).content[0].text).toBeDefined(); // Parse the JSON response const validation = JSON.parse((response as any).content[0].text); expect(validation).toBeDefined(); expect(validation).toHaveProperty('valid'); }); }); describe('Memory Efficiency', () => { it('should handle repeated operations without memory leaks', async () => { const iterations = 1000; const batchSize = 100; // Measure initial memory if available const initialMemory = process.memoryUsage(); for (let i = 0; i < iterations; i += batchSize) { const promises = []; for (let j = 0; j < batchSize; j++) { promises.push( client.callTool({ name: 'get_database_statistics', arguments: {} }) ); } await Promise.all(promises); // Force garbage collection if available if (global.gc) { global.gc(); } } const finalMemory = process.memoryUsage(); const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed; console.log(`Memory increase after ${iterations} operations: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`); // Memory increase should be reasonable (less than 50MB) expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); }); it('should release memory after large operations', async () => { const initialMemory = process.memoryUsage(); // Perform large operations for (let i = 0; i < 10; i++) { await client.callTool({ name: 'list_nodes', arguments: { limit: 200 } }); await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'nodes-base.httpRequest' } }); } // Force garbage collection if available if (global.gc) { global.gc(); await new Promise(resolve => setTimeout(resolve, 100)); } const finalMemory = process.memoryUsage(); const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed; console.log(`Memory increase after large operations: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`); // Should not retain excessive memory expect(memoryIncrease).toBeLessThan(20 * 1024 * 1024); }); }); describe('Scalability Tests', () => { it('should maintain performance with increasing load', async () => { const loadLevels = [10, 50, 100, 200]; const results: any[] = []; for (const load of loadLevels) { const start = performance.now(); const promises = []; for (let i = 0; i < load; i++) { promises.push( client.callTool({ name: 'list_nodes', arguments: { limit: 1 } }) ); } await Promise.all(promises); const duration = performance.now() - start; const avgTime = duration / load; results.push({ load, totalTime: duration, avgTime }); console.log(`Load ${load}: Total ${duration.toFixed(2)}ms, Avg ${avgTime.toFixed(2)}ms`); } // Average time should not increase dramatically with load const firstAvg = results[0].avgTime; const lastAvg = results[results.length - 1].avgTime; console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); console.log(`Performance scaling - First avg: ${firstAvg.toFixed(2)}ms, Last avg: ${lastAvg.toFixed(2)}ms`); // Environment-aware scaling factor const scalingFactor = process.env.CI ? 3 : 2; expect(lastAvg).toBeLessThan(firstAvg * scalingFactor); }); it('should handle burst traffic', async () => { const burstSize = 100; const start = performance.now(); // Simulate burst of requests const promises = []; for (let i = 0; i < burstSize; i++) { const operation = i % 4; switch (operation) { case 0: promises.push(client.callTool({ name: 'list_nodes', arguments: { limit: 5 } })); break; case 1: promises.push(client.callTool({ name: 'search_nodes', arguments: { query: 'test' } })); break; case 2: promises.push(client.callTool({ name: 'get_database_statistics', arguments: {} })); break; case 3: promises.push(client.callTool({ name: 'list_ai_tools', arguments: {} })); break; } } await Promise.all(promises); const duration = performance.now() - start; console.log(`Burst of ${burstSize} requests completed in ${duration.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Should handle burst within reasonable time const threshold = process.env.CI ? 2000 : 1000; expect(duration).toBeLessThan(threshold); }); }); describe('Critical Path Optimization', () => { it('should optimize tool listing performance', async () => { // Warm up with multiple calls to ensure everything is initialized for (let i = 0; i < 5; i++) { await client.callTool({ name: 'list_nodes', arguments: { limit: 1 } }); } const iterations = 100; const times: number[] = []; for (let i = 0; i < iterations; i++) { const start = performance.now(); await client.callTool({ name: 'list_nodes', arguments: { limit: 20 } }); times.push(performance.now() - start); } // Remove outliers (first few runs might be slower) times.sort((a, b) => a - b); const trimmedTimes = times.slice(10, -10); // Remove top and bottom 10% const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length; const minTime = Math.min(...trimmedTimes); const maxTime = Math.max(...trimmedTimes); console.log(`list_nodes performance - Avg: ${avgTime.toFixed(2)}ms, Min: ${minTime.toFixed(2)}ms, Max: ${maxTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Environment-aware thresholds const threshold = process.env.CI ? 25 : 10; expect(avgTime).toBeLessThan(threshold); // Max should not be too much higher than average (no outliers) // More lenient in CI due to resource contention const maxMultiplier = process.env.CI ? 5 : 3; expect(maxTime).toBeLessThan(avgTime * maxMultiplier); }); it('should optimize search performance', async () => { // Warm up with multiple calls for (let i = 0; i < 3; i++) { await client.callTool({ name: 'search_nodes', arguments: { query: 'test' } }); } const queries = ['http', 'webhook', 'database', 'api', 'slack']; const times: number[] = []; for (const query of queries) { for (let i = 0; i < 20; i++) { const start = performance.now(); await client.callTool({ name: 'search_nodes', arguments: { query } }); times.push(performance.now() - start); } } // Remove outliers times.sort((a, b) => a - b); const trimmedTimes = times.slice(10, -10); // Remove top and bottom 10% const avgTime = trimmedTimes.reduce((a, b) => a + b, 0) / trimmedTimes.length; console.log(`search_nodes average performance: ${avgTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Environment-aware threshold const threshold = process.env.CI ? 35 : 15; expect(avgTime).toBeLessThan(threshold); }); it('should cache effectively for repeated queries', async () => { const nodeType = 'nodes-base.httpRequest'; // First call (cold) const coldStart = performance.now(); await client.callTool({ name: 'get_node_info', arguments: { nodeType } }); const coldTime = performance.now() - coldStart; // Give cache time to settle await new Promise(resolve => setTimeout(resolve, 10)); // Subsequent calls (potentially cached) const warmTimes: number[] = []; for (let i = 0; i < 10; i++) { const start = performance.now(); await client.callTool({ name: 'get_node_info', arguments: { nodeType } }); warmTimes.push(performance.now() - start); } // Remove outliers from warm times warmTimes.sort((a, b) => a - b); const trimmedWarmTimes = warmTimes.slice(1, -1); // Remove highest and lowest const avgWarmTime = trimmedWarmTimes.reduce((a, b) => a + b, 0) / trimmedWarmTimes.length; console.log(`Cold time: ${coldTime.toFixed(2)}ms, Avg warm time: ${avgWarmTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // In CI, caching might not be as effective due to resource constraints const cacheMultiplier = process.env.CI ? 1.5 : 1.1; // Warm calls should be faster or at least not significantly slower expect(avgWarmTime).toBeLessThanOrEqual(coldTime * cacheMultiplier); }); }); describe('Stress Tests', () => { it('should handle sustained high load', async () => { const duration = 5000; // 5 seconds const start = performance.now(); let requestCount = 0; let errorCount = 0; while (performance.now() - start < duration) { try { await client.callTool({ name: 'get_database_statistics', arguments: {} }); requestCount++; } catch (error) { errorCount++; } } const actualDuration = performance.now() - start; const requestsPerSecond = requestCount / (actualDuration / 1000); console.log(`Sustained load test - Requests: ${requestCount}, RPS: ${requestsPerSecond.toFixed(2)}, Errors: ${errorCount}`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Environment-aware RPS threshold (relaxed -8% for type safety overhead) const rpsThreshold = process.env.CI ? 50 : 92; expect(requestsPerSecond).toBeGreaterThan(rpsThreshold); // Error rate should be very low expect(errorCount).toBe(0); }); it('should recover from performance degradation', async () => { // Create heavy load const heavyPromises = []; for (let i = 0; i < 200; i++) { heavyPromises.push( client.callTool({ name: 'validate_workflow', arguments: { workflow: { nodes: Array(20).fill(null).map((_, idx) => ({ id: String(idx), name: `Node${idx}`, type: 'nodes-base.set', typeVersion: 1, position: [idx * 100, 0], parameters: {} })), connections: {} } } }) ); } await Promise.all(heavyPromises); // Measure performance after heavy load const recoveryTimes: number[] = []; for (let i = 0; i < 10; i++) { const start = performance.now(); await client.callTool({ name: 'get_database_statistics', arguments: {} }); recoveryTimes.push(performance.now() - start); } const avgRecoveryTime = recoveryTimes.reduce((a, b) => a + b, 0) / recoveryTimes.length; console.log(`Average response time after heavy load: ${avgRecoveryTime.toFixed(2)}ms`); console.log(`Environment: ${process.env.CI ? 'CI' : 'Local'}`); // Should recover to normal performance (relaxed +20% for type safety overhead) const threshold = process.env.CI ? 25 : 12; expect(avgRecoveryTime).toBeLessThan(threshold); }); }); }); ```