This is page 26 of 60. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CI_TEST_INFRASTRUCTURE.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── skills.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── 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 │ ├── expression-utils.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 │ │ │ ├── expression-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 -------------------------------------------------------------------------------- /scripts/test-release-automation.js: -------------------------------------------------------------------------------- ```javascript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Test script for release automation 5 | * Validates the release workflow components locally 6 | */ 7 | 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const { execSync } = require('child_process'); 11 | 12 | // Color codes for output 13 | const colors = { 14 | reset: '\x1b[0m', 15 | red: '\x1b[31m', 16 | green: '\x1b[32m', 17 | yellow: '\x1b[33m', 18 | blue: '\x1b[34m', 19 | magenta: '\x1b[35m', 20 | cyan: '\x1b[36m' 21 | }; 22 | 23 | function log(message, color = 'reset') { 24 | console.log(`${colors[color]}${message}${colors.reset}`); 25 | } 26 | 27 | function header(title) { 28 | log(`\n${'='.repeat(60)}`, 'cyan'); 29 | log(`🧪 ${title}`, 'cyan'); 30 | log(`${'='.repeat(60)}`, 'cyan'); 31 | } 32 | 33 | function section(title) { 34 | log(`\n📋 ${title}`, 'blue'); 35 | log(`${'-'.repeat(40)}`, 'blue'); 36 | } 37 | 38 | function success(message) { 39 | log(`✅ ${message}`, 'green'); 40 | } 41 | 42 | function warning(message) { 43 | log(`⚠️ ${message}`, 'yellow'); 44 | } 45 | 46 | function error(message) { 47 | log(`❌ ${message}`, 'red'); 48 | } 49 | 50 | function info(message) { 51 | log(`ℹ️ ${message}`, 'blue'); 52 | } 53 | 54 | class ReleaseAutomationTester { 55 | constructor() { 56 | this.rootDir = path.resolve(__dirname, '..'); 57 | this.errors = []; 58 | this.warnings = []; 59 | } 60 | 61 | /** 62 | * Test if required files exist 63 | */ 64 | testFileExistence() { 65 | section('Testing File Existence'); 66 | 67 | const requiredFiles = [ 68 | 'package.json', 69 | 'package.runtime.json', 70 | 'docs/CHANGELOG.md', 71 | '.github/workflows/release.yml', 72 | 'scripts/sync-runtime-version.js', 73 | 'scripts/publish-npm.sh' 74 | ]; 75 | 76 | for (const file of requiredFiles) { 77 | const filePath = path.join(this.rootDir, file); 78 | if (fs.existsSync(filePath)) { 79 | success(`Found: ${file}`); 80 | } else { 81 | error(`Missing: ${file}`); 82 | this.errors.push(`Missing required file: ${file}`); 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * Test version detection logic 89 | */ 90 | testVersionDetection() { 91 | section('Testing Version Detection'); 92 | 93 | try { 94 | const packageJson = require(path.join(this.rootDir, 'package.json')); 95 | const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json')); 96 | 97 | success(`Package.json version: ${packageJson.version}`); 98 | success(`Runtime package version: ${runtimeJson.version}`); 99 | 100 | if (packageJson.version === runtimeJson.version) { 101 | success('Version sync: Both versions match'); 102 | } else { 103 | warning('Version sync: Versions do not match - run sync:runtime-version'); 104 | this.warnings.push('Package versions are not synchronized'); 105 | } 106 | 107 | // Test semantic version format 108 | const semverRegex = /^\d+\.\d+\.\d+(?:-[\w\.-]+)?(?:\+[\w\.-]+)?$/; 109 | if (semverRegex.test(packageJson.version)) { 110 | success(`Version format: Valid semantic version (${packageJson.version})`); 111 | } else { 112 | error(`Version format: Invalid semantic version (${packageJson.version})`); 113 | this.errors.push('Invalid semantic version format'); 114 | } 115 | 116 | } catch (err) { 117 | error(`Version detection failed: ${err.message}`); 118 | this.errors.push(`Version detection error: ${err.message}`); 119 | } 120 | } 121 | 122 | /** 123 | * Test changelog parsing 124 | */ 125 | testChangelogParsing() { 126 | section('Testing Changelog Parsing'); 127 | 128 | try { 129 | const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md'); 130 | 131 | if (!fs.existsSync(changelogPath)) { 132 | error('Changelog file not found'); 133 | this.errors.push('Missing changelog file'); 134 | return; 135 | } 136 | 137 | const changelogContent = fs.readFileSync(changelogPath, 'utf8'); 138 | const packageJson = require(path.join(this.rootDir, 'package.json')); 139 | const currentVersion = packageJson.version; 140 | 141 | // Check if current version exists in changelog 142 | const versionRegex = new RegExp(`^## \\[${currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm'); 143 | 144 | if (versionRegex.test(changelogContent)) { 145 | success(`Changelog entry found for version ${currentVersion}`); 146 | 147 | // Test extraction logic (simplified version of the GitHub Actions script) 148 | const lines = changelogContent.split('\n'); 149 | let startIndex = -1; 150 | let endIndex = -1; 151 | 152 | for (let i = 0; i < lines.length; i++) { 153 | if (versionRegex.test(lines[i])) { 154 | startIndex = i; 155 | break; 156 | } 157 | } 158 | 159 | if (startIndex !== -1) { 160 | // Find the end of this version's section 161 | for (let i = startIndex + 1; i < lines.length; i++) { 162 | if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) { 163 | endIndex = i; 164 | break; 165 | } 166 | } 167 | 168 | if (endIndex === -1) { 169 | endIndex = lines.length; 170 | } 171 | 172 | const sectionLines = lines.slice(startIndex + 1, endIndex); 173 | const contentLines = sectionLines.filter(line => line.trim() !== ''); 174 | 175 | if (contentLines.length > 0) { 176 | success(`Changelog content extracted: ${contentLines.length} lines`); 177 | info(`Preview: ${contentLines[0].substring(0, 100)}...`); 178 | } else { 179 | warning('Changelog section appears to be empty'); 180 | this.warnings.push(`Empty changelog section for version ${currentVersion}`); 181 | } 182 | } 183 | 184 | } else { 185 | warning(`No changelog entry found for current version ${currentVersion}`); 186 | this.warnings.push(`Missing changelog entry for version ${currentVersion}`); 187 | } 188 | 189 | // Check changelog format 190 | if (changelogContent.includes('## [Unreleased]')) { 191 | success('Changelog format: Contains Unreleased section'); 192 | } else { 193 | warning('Changelog format: Missing Unreleased section'); 194 | } 195 | 196 | if (changelogContent.includes('Keep a Changelog')) { 197 | success('Changelog format: Follows Keep a Changelog format'); 198 | } else { 199 | warning('Changelog format: Does not reference Keep a Changelog'); 200 | } 201 | 202 | } catch (err) { 203 | error(`Changelog parsing failed: ${err.message}`); 204 | this.errors.push(`Changelog parsing error: ${err.message}`); 205 | } 206 | } 207 | 208 | /** 209 | * Test build process 210 | */ 211 | testBuildProcess() { 212 | section('Testing Build Process'); 213 | 214 | try { 215 | // Check if dist directory exists 216 | const distPath = path.join(this.rootDir, 'dist'); 217 | if (fs.existsSync(distPath)) { 218 | success('Build output: dist directory exists'); 219 | 220 | // Check for key build files 221 | const keyFiles = [ 222 | 'dist/index.js', 223 | 'dist/mcp/index.js', 224 | 'dist/mcp/server.js' 225 | ]; 226 | 227 | for (const file of keyFiles) { 228 | const filePath = path.join(this.rootDir, file); 229 | if (fs.existsSync(filePath)) { 230 | success(`Build file: ${file} exists`); 231 | } else { 232 | warning(`Build file: ${file} missing - run 'npm run build'`); 233 | this.warnings.push(`Missing build file: ${file}`); 234 | } 235 | } 236 | 237 | } else { 238 | warning('Build output: dist directory missing - run "npm run build"'); 239 | this.warnings.push('Missing build output'); 240 | } 241 | 242 | // Check database 243 | const dbPath = path.join(this.rootDir, 'data/nodes.db'); 244 | if (fs.existsSync(dbPath)) { 245 | const stats = fs.statSync(dbPath); 246 | success(`Database: nodes.db exists (${Math.round(stats.size / 1024 / 1024)}MB)`); 247 | } else { 248 | warning('Database: nodes.db missing - run "npm run rebuild"'); 249 | this.warnings.push('Missing database file'); 250 | } 251 | 252 | } catch (err) { 253 | error(`Build process test failed: ${err.message}`); 254 | this.errors.push(`Build process error: ${err.message}`); 255 | } 256 | } 257 | 258 | /** 259 | * Test npm publish preparation 260 | */ 261 | testNpmPublishPrep() { 262 | section('Testing NPM Publish Preparation'); 263 | 264 | try { 265 | const packageJson = require(path.join(this.rootDir, 'package.json')); 266 | const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json')); 267 | 268 | // Check package.json fields 269 | const requiredFields = ['name', 'version', 'description', 'main', 'bin']; 270 | for (const field of requiredFields) { 271 | if (packageJson[field]) { 272 | success(`Package field: ${field} is present`); 273 | } else { 274 | error(`Package field: ${field} is missing`); 275 | this.errors.push(`Missing package.json field: ${field}`); 276 | } 277 | } 278 | 279 | // Check runtime dependencies 280 | if (runtimeJson.dependencies) { 281 | const depCount = Object.keys(runtimeJson.dependencies).length; 282 | success(`Runtime dependencies: ${depCount} packages`); 283 | 284 | // List key dependencies 285 | const keyDeps = ['@modelcontextprotocol/sdk', 'express', 'sql.js']; 286 | for (const dep of keyDeps) { 287 | if (runtimeJson.dependencies[dep]) { 288 | success(`Key dependency: ${dep} (${runtimeJson.dependencies[dep]})`); 289 | } else { 290 | warning(`Key dependency: ${dep} is missing`); 291 | this.warnings.push(`Missing key dependency: ${dep}`); 292 | } 293 | } 294 | 295 | } else { 296 | error('Runtime package has no dependencies'); 297 | this.errors.push('Missing runtime dependencies'); 298 | } 299 | 300 | // Check files array 301 | if (packageJson.files && Array.isArray(packageJson.files)) { 302 | success(`Package files: ${packageJson.files.length} patterns specified`); 303 | info(`Files: ${packageJson.files.join(', ')}`); 304 | } else { 305 | warning('Package files: No files array specified'); 306 | this.warnings.push('No files array in package.json'); 307 | } 308 | 309 | } catch (err) { 310 | error(`NPM publish prep test failed: ${err.message}`); 311 | this.errors.push(`NPM publish prep error: ${err.message}`); 312 | } 313 | } 314 | 315 | /** 316 | * Test Docker configuration 317 | */ 318 | testDockerConfig() { 319 | section('Testing Docker Configuration'); 320 | 321 | try { 322 | const dockerfiles = ['Dockerfile', 'Dockerfile.railway']; 323 | 324 | for (const dockerfile of dockerfiles) { 325 | const dockerfilePath = path.join(this.rootDir, dockerfile); 326 | if (fs.existsSync(dockerfilePath)) { 327 | success(`Dockerfile: ${dockerfile} exists`); 328 | 329 | const content = fs.readFileSync(dockerfilePath, 'utf8'); 330 | 331 | // Check for key instructions 332 | if (content.includes('FROM node:')) { 333 | success(`${dockerfile}: Uses Node.js base image`); 334 | } else { 335 | warning(`${dockerfile}: Does not use standard Node.js base image`); 336 | } 337 | 338 | if (content.includes('COPY dist')) { 339 | success(`${dockerfile}: Copies build output`); 340 | } else { 341 | warning(`${dockerfile}: May not copy build output correctly`); 342 | } 343 | 344 | } else { 345 | warning(`Dockerfile: ${dockerfile} not found`); 346 | this.warnings.push(`Missing Dockerfile: ${dockerfile}`); 347 | } 348 | } 349 | 350 | // Check docker-compose files 351 | const composeFiles = ['docker-compose.yml', 'docker-compose.n8n.yml']; 352 | for (const composeFile of composeFiles) { 353 | const composePath = path.join(this.rootDir, composeFile); 354 | if (fs.existsSync(composePath)) { 355 | success(`Docker Compose: ${composeFile} exists`); 356 | } else { 357 | info(`Docker Compose: ${composeFile} not found (optional)`); 358 | } 359 | } 360 | 361 | } catch (err) { 362 | error(`Docker config test failed: ${err.message}`); 363 | this.errors.push(`Docker config error: ${err.message}`); 364 | } 365 | } 366 | 367 | /** 368 | * Test workflow file syntax 369 | */ 370 | testWorkflowSyntax() { 371 | section('Testing Workflow Syntax'); 372 | 373 | try { 374 | const workflowPath = path.join(this.rootDir, '.github/workflows/release.yml'); 375 | 376 | if (!fs.existsSync(workflowPath)) { 377 | error('Release workflow file not found'); 378 | this.errors.push('Missing release workflow file'); 379 | return; 380 | } 381 | 382 | const workflowContent = fs.readFileSync(workflowPath, 'utf8'); 383 | 384 | // Basic YAML structure checks 385 | if (workflowContent.includes('name: Automated Release')) { 386 | success('Workflow: Has correct name'); 387 | } else { 388 | warning('Workflow: Name may be incorrect'); 389 | } 390 | 391 | if (workflowContent.includes('on:') && workflowContent.includes('push:')) { 392 | success('Workflow: Has push trigger'); 393 | } else { 394 | error('Workflow: Missing push trigger'); 395 | this.errors.push('Workflow missing push trigger'); 396 | } 397 | 398 | if (workflowContent.includes('branches: [main]')) { 399 | success('Workflow: Configured for main branch'); 400 | } else { 401 | warning('Workflow: May not be configured for main branch'); 402 | } 403 | 404 | // Check for required jobs 405 | const requiredJobs = [ 406 | 'detect-version-change', 407 | 'extract-changelog', 408 | 'create-release', 409 | 'publish-npm', 410 | 'build-docker' 411 | ]; 412 | 413 | for (const job of requiredJobs) { 414 | if (workflowContent.includes(`${job}:`)) { 415 | success(`Workflow job: ${job} defined`); 416 | } else { 417 | error(`Workflow job: ${job} missing`); 418 | this.errors.push(`Missing workflow job: ${job}`); 419 | } 420 | } 421 | 422 | // Check for secrets usage 423 | if (workflowContent.includes('${{ secrets.NPM_TOKEN }}')) { 424 | success('Workflow: NPM_TOKEN secret configured'); 425 | } else { 426 | warning('Workflow: NPM_TOKEN secret may be missing'); 427 | this.warnings.push('NPM_TOKEN secret may need to be configured'); 428 | } 429 | 430 | if (workflowContent.includes('${{ secrets.GITHUB_TOKEN }}')) { 431 | success('Workflow: GITHUB_TOKEN secret configured'); 432 | } else { 433 | warning('Workflow: GITHUB_TOKEN secret may be missing'); 434 | } 435 | 436 | } catch (err) { 437 | error(`Workflow syntax test failed: ${err.message}`); 438 | this.errors.push(`Workflow syntax error: ${err.message}`); 439 | } 440 | } 441 | 442 | /** 443 | * Test environment and dependencies 444 | */ 445 | testEnvironment() { 446 | section('Testing Environment'); 447 | 448 | try { 449 | // Check Node.js version 450 | const nodeVersion = process.version; 451 | success(`Node.js version: ${nodeVersion}`); 452 | 453 | // Check if npm is available 454 | try { 455 | const npmVersion = execSync('npm --version', { encoding: 'utf8', stdio: 'pipe' }).trim(); 456 | success(`NPM version: ${npmVersion}`); 457 | } catch (err) { 458 | error('NPM not available'); 459 | this.errors.push('NPM not available'); 460 | } 461 | 462 | // Check if git is available 463 | try { 464 | const gitVersion = execSync('git --version', { encoding: 'utf8', stdio: 'pipe' }).trim(); 465 | success(`Git available: ${gitVersion}`); 466 | } catch (err) { 467 | error('Git not available'); 468 | this.errors.push('Git not available'); 469 | } 470 | 471 | // Check if we're in a git repository 472 | try { 473 | execSync('git rev-parse --git-dir', { stdio: 'pipe' }); 474 | success('Git repository: Detected'); 475 | 476 | // Check current branch 477 | try { 478 | const branch = execSync('git branch --show-current', { encoding: 'utf8', stdio: 'pipe' }).trim(); 479 | info(`Current branch: ${branch}`); 480 | } catch (err) { 481 | info('Could not determine current branch'); 482 | } 483 | 484 | } catch (err) { 485 | warning('Not in a git repository'); 486 | this.warnings.push('Not in a git repository'); 487 | } 488 | 489 | } catch (err) { 490 | error(`Environment test failed: ${err.message}`); 491 | this.errors.push(`Environment error: ${err.message}`); 492 | } 493 | } 494 | 495 | /** 496 | * Run all tests 497 | */ 498 | async runAllTests() { 499 | header('Release Automation Test Suite'); 500 | 501 | info('Testing release automation components...'); 502 | 503 | this.testFileExistence(); 504 | this.testVersionDetection(); 505 | this.testChangelogParsing(); 506 | this.testBuildProcess(); 507 | this.testNpmPublishPrep(); 508 | this.testDockerConfig(); 509 | this.testWorkflowSyntax(); 510 | this.testEnvironment(); 511 | 512 | // Summary 513 | header('Test Summary'); 514 | 515 | if (this.errors.length === 0 && this.warnings.length === 0) { 516 | log('🎉 All tests passed! Release automation is ready.', 'green'); 517 | } else { 518 | if (this.errors.length > 0) { 519 | log(`\n❌ ${this.errors.length} Error(s):`, 'red'); 520 | this.errors.forEach(err => log(` • ${err}`, 'red')); 521 | } 522 | 523 | if (this.warnings.length > 0) { 524 | log(`\n⚠️ ${this.warnings.length} Warning(s):`, 'yellow'); 525 | this.warnings.forEach(warn => log(` • ${warn}`, 'yellow')); 526 | } 527 | 528 | if (this.errors.length > 0) { 529 | log('\n🔧 Please fix the errors before running the release workflow.', 'red'); 530 | process.exit(1); 531 | } else { 532 | log('\n✅ No critical errors found. Warnings should be reviewed but won\'t prevent releases.', 'yellow'); 533 | } 534 | } 535 | 536 | // Next steps 537 | log('\n📋 Next Steps:', 'cyan'); 538 | log('1. Ensure all secrets are configured in GitHub repository settings:', 'cyan'); 539 | log(' • NPM_TOKEN (required for npm publishing)', 'cyan'); 540 | log(' • GITHUB_TOKEN (automatically available)', 'cyan'); 541 | log('\n2. To trigger a release:', 'cyan'); 542 | log(' • Update version in package.json', 'cyan'); 543 | log(' • Update changelog in docs/CHANGELOG.md', 'cyan'); 544 | log(' • Commit and push to main branch', 'cyan'); 545 | log('\n3. Monitor the release workflow in GitHub Actions', 'cyan'); 546 | 547 | return this.errors.length === 0; 548 | } 549 | } 550 | 551 | // Run the tests 552 | if (require.main === module) { 553 | const tester = new ReleaseAutomationTester(); 554 | tester.runAllTests().catch(err => { 555 | console.error('Test suite failed:', err); 556 | process.exit(1); 557 | }); 558 | } 559 | 560 | module.exports = ReleaseAutomationTester; ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/error-handling.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; 3 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 4 | import { TestableN8NMCPServer } from './test-helpers'; 5 | 6 | describe('MCP Error Handling', () => { 7 | let mcpServer: TestableN8NMCPServer; 8 | let client: Client; 9 | 10 | beforeEach(async () => { 11 | mcpServer = new TestableN8NMCPServer(); 12 | await mcpServer.initialize(); 13 | 14 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); 15 | await mcpServer.connectToTransport(serverTransport); 16 | 17 | client = new Client({ 18 | name: 'test-client', 19 | version: '1.0.0' 20 | }, { 21 | capabilities: {} 22 | }); 23 | 24 | await client.connect(clientTransport); 25 | }); 26 | 27 | afterEach(async () => { 28 | await client.close(); 29 | await mcpServer.close(); 30 | }); 31 | 32 | describe('JSON-RPC Error Codes', () => { 33 | it('should handle invalid request (parse error)', async () => { 34 | // The MCP SDK handles parsing, so we test with invalid method instead 35 | try { 36 | await (client as any).request({ 37 | method: '', // Empty method 38 | params: {} 39 | }); 40 | expect.fail('Should have thrown an error'); 41 | } catch (error: any) { 42 | expect(error).toBeDefined(); 43 | } 44 | }); 45 | 46 | it('should handle method not found', async () => { 47 | try { 48 | await (client as any).request({ 49 | method: 'nonexistent/method', 50 | params: {} 51 | }); 52 | expect.fail('Should have thrown an error'); 53 | } catch (error: any) { 54 | expect(error).toBeDefined(); 55 | expect(error.message).toContain('not found'); 56 | } 57 | }); 58 | 59 | it('should handle invalid params', async () => { 60 | try { 61 | // Missing required parameter 62 | await client.callTool({ name: 'get_node_info', arguments: {} }); 63 | expect.fail('Should have thrown an error'); 64 | } catch (error: any) { 65 | expect(error).toBeDefined(); 66 | // The error now properly validates required parameters 67 | expect(error.message).toContain("Missing required parameters"); 68 | } 69 | }); 70 | 71 | it('should handle internal errors gracefully', async () => { 72 | try { 73 | // Invalid node type format should cause internal processing error 74 | await client.callTool({ name: 'get_node_info', arguments: { 75 | nodeType: 'completely-invalid-format-$$$$' 76 | } }); 77 | expect.fail('Should have thrown an error'); 78 | } catch (error: any) { 79 | expect(error).toBeDefined(); 80 | expect(error.message).toContain('not found'); 81 | } 82 | }); 83 | }); 84 | 85 | describe('Tool-Specific Errors', () => { 86 | describe('Node Discovery Errors', () => { 87 | it('should handle invalid category filter', async () => { 88 | const response = await client.callTool({ name: 'list_nodes', arguments: { 89 | category: 'invalid_category' 90 | } }); 91 | 92 | // Should return empty array, not error 93 | const result = JSON.parse((response as any).content[0].text); 94 | expect(result).toHaveProperty('nodes'); 95 | expect(Array.isArray(result.nodes)).toBe(true); 96 | expect(result.nodes).toHaveLength(0); 97 | }); 98 | 99 | it('should handle invalid search mode', async () => { 100 | try { 101 | await client.callTool({ name: 'search_nodes', arguments: { 102 | query: 'test', 103 | mode: 'INVALID_MODE' as any 104 | } }); 105 | expect.fail('Should have thrown an error'); 106 | } catch (error: any) { 107 | expect(error).toBeDefined(); 108 | } 109 | }); 110 | 111 | it('should handle empty search query', async () => { 112 | try { 113 | await client.callTool({ name: 'search_nodes', arguments: { 114 | query: '' 115 | } }); 116 | expect.fail('Should have thrown an error'); 117 | } catch (error: any) { 118 | expect(error).toBeDefined(); 119 | expect(error.message).toContain("search_nodes: Validation failed:"); 120 | expect(error.message).toContain("query: query cannot be empty"); 121 | } 122 | }); 123 | 124 | it('should handle non-existent node types', async () => { 125 | try { 126 | await client.callTool({ name: 'get_node_info', arguments: { 127 | nodeType: 'nodes-base.thisDoesNotExist' 128 | } }); 129 | expect.fail('Should have thrown an error'); 130 | } catch (error: any) { 131 | expect(error).toBeDefined(); 132 | expect(error.message).toContain('not found'); 133 | } 134 | }); 135 | }); 136 | 137 | describe('Validation Errors', () => { 138 | it('should handle invalid validation profile', async () => { 139 | try { 140 | await client.callTool({ name: 'validate_node_operation', arguments: { 141 | nodeType: 'nodes-base.httpRequest', 142 | config: { method: 'GET', url: 'https://api.example.com' }, 143 | profile: 'invalid_profile' as any 144 | } }); 145 | expect.fail('Should have thrown an error'); 146 | } catch (error: any) { 147 | expect(error).toBeDefined(); 148 | } 149 | }); 150 | 151 | it('should handle malformed workflow structure', async () => { 152 | try { 153 | await client.callTool({ name: 'validate_workflow', arguments: { 154 | workflow: { 155 | // Missing required 'nodes' array 156 | connections: {} 157 | } 158 | } }); 159 | expect.fail('Should have thrown an error'); 160 | } catch (error: any) { 161 | expect(error).toBeDefined(); 162 | expect(error.message).toContain("validate_workflow: Validation failed:"); 163 | expect(error.message).toContain("workflow.nodes: workflow.nodes is required"); 164 | } 165 | }); 166 | 167 | it('should handle circular workflow references', async () => { 168 | const workflow = { 169 | nodes: [ 170 | { 171 | id: '1', 172 | name: 'Node1', 173 | type: 'nodes-base.noOp', 174 | typeVersion: 1, 175 | position: [0, 0], 176 | parameters: {} 177 | }, 178 | { 179 | id: '2', 180 | name: 'Node2', 181 | type: 'nodes-base.noOp', 182 | typeVersion: 1, 183 | position: [250, 0], 184 | parameters: {} 185 | } 186 | ], 187 | connections: { 188 | 'Node1': { 189 | 'main': [[{ node: 'Node2', type: 'main', index: 0 }]] 190 | }, 191 | 'Node2': { 192 | 'main': [[{ node: 'Node1', type: 'main', index: 0 }]] 193 | } 194 | } 195 | }; 196 | 197 | const response = await client.callTool({ name: 'validate_workflow', arguments: { 198 | workflow 199 | } }); 200 | 201 | const validation = JSON.parse((response as any).content[0].text); 202 | expect(validation.warnings).toBeDefined(); 203 | }); 204 | }); 205 | 206 | describe('Documentation Errors', () => { 207 | it('should handle non-existent documentation topics', async () => { 208 | const response = await client.callTool({ name: 'tools_documentation', arguments: { 209 | topic: 'completely_fake_tool' 210 | } }); 211 | 212 | expect((response as any).content[0].text).toContain('not found'); 213 | }); 214 | 215 | it('should handle invalid depth parameter', async () => { 216 | try { 217 | await client.callTool({ name: 'tools_documentation', arguments: { 218 | depth: 'invalid_depth' as any 219 | } }); 220 | expect.fail('Should have thrown an error'); 221 | } catch (error: any) { 222 | expect(error).toBeDefined(); 223 | } 224 | }); 225 | }); 226 | }); 227 | 228 | describe('Large Payload Handling', () => { 229 | it('should handle large node info requests', async () => { 230 | // HTTP Request node has extensive properties 231 | const response = await client.callTool({ name: 'get_node_info', arguments: { 232 | nodeType: 'nodes-base.httpRequest' 233 | } }); 234 | 235 | expect((response as any).content[0].text.length).toBeGreaterThan(10000); 236 | 237 | // Should be valid JSON 238 | const nodeInfo = JSON.parse((response as any).content[0].text); 239 | expect(nodeInfo).toHaveProperty('properties'); 240 | }); 241 | 242 | it('should handle large workflow validation', async () => { 243 | // Create a large workflow 244 | const nodes = []; 245 | const connections: any = {}; 246 | 247 | for (let i = 0; i < 50; i++) { 248 | const nodeName = `Node${i}`; 249 | nodes.push({ 250 | id: String(i), 251 | name: nodeName, 252 | type: 'nodes-base.noOp', 253 | typeVersion: 1, 254 | position: [i * 100, 0], 255 | parameters: {} 256 | }); 257 | 258 | if (i > 0) { 259 | const prevNode = `Node${i - 1}`; 260 | connections[prevNode] = { 261 | 'main': [[{ node: nodeName, type: 'main', index: 0 }]] 262 | }; 263 | } 264 | } 265 | 266 | const response = await client.callTool({ name: 'validate_workflow', arguments: { 267 | workflow: { nodes, connections } 268 | } }); 269 | 270 | const validation = JSON.parse((response as any).content[0].text); 271 | expect(validation).toHaveProperty('valid'); 272 | }); 273 | 274 | it('should handle many concurrent requests', async () => { 275 | const requestCount = 50; 276 | const promises = []; 277 | 278 | for (let i = 0; i < requestCount; i++) { 279 | promises.push( 280 | client.callTool({ name: 'list_nodes', arguments: { 281 | limit: 1, 282 | category: i % 2 === 0 ? 'trigger' : 'transform' 283 | } }) 284 | ); 285 | } 286 | 287 | const responses = await Promise.all(promises); 288 | expect(responses).toHaveLength(requestCount); 289 | }); 290 | }); 291 | 292 | describe('Invalid JSON Handling', () => { 293 | it('should handle invalid JSON in tool parameters', async () => { 294 | try { 295 | // Config should be an object, not a string 296 | await client.callTool({ name: 'validate_node_operation', arguments: { 297 | nodeType: 'nodes-base.httpRequest', 298 | config: 'invalid json string' as any 299 | } }); 300 | expect.fail('Should have thrown an error'); 301 | } catch (error: any) { 302 | expect(error).toBeDefined(); 303 | } 304 | }); 305 | 306 | it('should handle malformed workflow JSON', async () => { 307 | try { 308 | await client.callTool({ name: 'validate_workflow', arguments: { 309 | workflow: 'not a valid workflow object' as any 310 | } }); 311 | expect.fail('Should have thrown an error'); 312 | } catch (error: any) { 313 | expect(error).toBeDefined(); 314 | } 315 | }); 316 | }); 317 | 318 | describe('Timeout Scenarios', () => { 319 | it('should handle rapid sequential requests', async () => { 320 | const start = Date.now(); 321 | 322 | for (let i = 0; i < 20; i++) { 323 | await client.callTool({ name: 'get_database_statistics', arguments: {} }); 324 | } 325 | 326 | const duration = Date.now() - start; 327 | 328 | // Should complete reasonably quickly (under 5 seconds) 329 | expect(duration).toBeLessThan(5000); 330 | }); 331 | 332 | it('should handle long-running operations', async () => { 333 | // Search with complex query that requires more processing 334 | const response = await client.callTool({ name: 'search_nodes', arguments: { 335 | query: 'a b c d e f g h i j k l m n o p q r s t u v w x y z', 336 | mode: 'AND' 337 | } }); 338 | 339 | expect(response).toBeDefined(); 340 | }); 341 | }); 342 | 343 | describe('Memory Pressure', () => { 344 | it('should handle multiple large responses', async () => { 345 | const promises = []; 346 | 347 | // Request multiple large node infos 348 | const largeNodes = [ 349 | 'nodes-base.httpRequest', 350 | 'nodes-base.postgres', 351 | 'nodes-base.googleSheets', 352 | 'nodes-base.slack', 353 | 'nodes-base.gmail' 354 | ]; 355 | 356 | for (const nodeType of largeNodes) { 357 | promises.push( 358 | client.callTool({ name: 'get_node_info', arguments: { nodeType } }) 359 | .catch(() => null) // Some might not exist 360 | ); 361 | } 362 | 363 | const responses = await Promise.all(promises); 364 | const validResponses = responses.filter(r => r !== null); 365 | 366 | expect(validResponses.length).toBeGreaterThan(0); 367 | }); 368 | 369 | it('should handle workflow with many nodes', async () => { 370 | const nodeCount = 100; 371 | const nodes = []; 372 | 373 | for (let i = 0; i < nodeCount; i++) { 374 | nodes.push({ 375 | id: String(i), 376 | name: `Node${i}`, 377 | type: 'nodes-base.noOp', 378 | typeVersion: 1, 379 | position: [i * 50, Math.floor(i / 10) * 100], 380 | parameters: { 381 | // Add some data to increase memory usage 382 | data: `This is some test data for node ${i}`.repeat(10) 383 | } 384 | }); 385 | } 386 | 387 | const response = await client.callTool({ name: 'validate_workflow', arguments: { 388 | workflow: { 389 | nodes, 390 | connections: {} 391 | } 392 | } }); 393 | 394 | const validation = JSON.parse((response as any).content[0].text); 395 | expect(validation).toHaveProperty('valid'); 396 | }); 397 | }); 398 | 399 | describe('Error Recovery', () => { 400 | it('should continue working after errors', async () => { 401 | // Cause an error 402 | try { 403 | await client.callTool({ name: 'get_node_info', arguments: { 404 | nodeType: 'invalid' 405 | } }); 406 | } catch (error) { 407 | // Expected 408 | } 409 | 410 | // Should still work 411 | const response = await client.callTool({ name: 'list_nodes', arguments: { limit: 1 } }); 412 | expect(response).toBeDefined(); 413 | }); 414 | 415 | it('should handle mixed success and failure', async () => { 416 | const promises = [ 417 | client.callTool({ name: 'list_nodes', arguments: { limit: 5 } }), 418 | client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid' } }).catch(e => ({ error: e })), 419 | client.callTool({ name: 'get_database_statistics', arguments: {} }), 420 | client.callTool({ name: 'search_nodes', arguments: { query: '' } }).catch(e => ({ error: e })), 421 | client.callTool({ name: 'list_ai_tools', arguments: {} }) 422 | ]; 423 | 424 | const results = await Promise.all(promises); 425 | 426 | // Some should succeed, some should fail 427 | const successes = results.filter(r => !('error' in r)); 428 | const failures = results.filter(r => 'error' in r); 429 | 430 | expect(successes.length).toBeGreaterThan(0); 431 | expect(failures.length).toBeGreaterThan(0); 432 | }); 433 | }); 434 | 435 | describe('Edge Cases', () => { 436 | it('should handle empty responses gracefully', async () => { 437 | const response = await client.callTool({ name: 'list_nodes', arguments: { 438 | category: 'nonexistent_category' 439 | } }); 440 | 441 | const result = JSON.parse((response as any).content[0].text); 442 | expect(result).toHaveProperty('nodes'); 443 | expect(Array.isArray(result.nodes)).toBe(true); 444 | expect(result.nodes).toHaveLength(0); 445 | }); 446 | 447 | it('should handle special characters in parameters', async () => { 448 | const response = await client.callTool({ name: 'search_nodes', arguments: { 449 | query: 'test!@#$%^&*()_+-=[]{}|;\':",./<>?' 450 | } }); 451 | 452 | // Should return results or empty array, not error 453 | const result = JSON.parse((response as any).content[0].text); 454 | expect(result).toHaveProperty('results'); 455 | expect(Array.isArray(result.results)).toBe(true); 456 | }); 457 | 458 | it('should handle unicode in parameters', async () => { 459 | const response = await client.callTool({ name: 'search_nodes', arguments: { 460 | query: 'test 测试 тест परीक्षण' 461 | } }); 462 | 463 | const result = JSON.parse((response as any).content[0].text); 464 | expect(result).toHaveProperty('results'); 465 | expect(Array.isArray(result.results)).toBe(true); 466 | }); 467 | 468 | it('should handle null and undefined gracefully', async () => { 469 | // Most tools should handle missing optional params 470 | const response = await client.callTool({ name: 'list_nodes', arguments: { 471 | limit: undefined as any, 472 | category: null as any 473 | } }); 474 | 475 | const result = JSON.parse((response as any).content[0].text); 476 | expect(result).toHaveProperty('nodes'); 477 | expect(Array.isArray(result.nodes)).toBe(true); 478 | }); 479 | }); 480 | 481 | describe('Error Message Quality', () => { 482 | it('should provide helpful error messages', async () => { 483 | try { 484 | // Use a truly invalid node type 485 | await client.callTool({ name: 'get_node_info', arguments: { 486 | nodeType: 'invalid-node-type-that-does-not-exist' 487 | } }); 488 | expect.fail('Should have thrown an error'); 489 | } catch (error: any) { 490 | expect(error.message).toBeDefined(); 491 | expect(error.message.length).toBeGreaterThan(10); 492 | // Should mention the issue 493 | expect(error.message.toLowerCase()).toMatch(/not found|invalid|missing/); 494 | } 495 | }); 496 | 497 | it('should indicate missing required parameters', async () => { 498 | try { 499 | await client.callTool({ name: 'search_nodes', arguments: {} }); 500 | expect.fail('Should have thrown an error'); 501 | } catch (error: any) { 502 | expect(error).toBeDefined(); 503 | // The error now properly validates required parameters 504 | expect(error.message).toContain("search_nodes: Validation failed:"); 505 | expect(error.message).toContain("query: query is required"); 506 | } 507 | }); 508 | 509 | it('should provide context for validation errors', async () => { 510 | const response = await client.callTool({ name: 'validate_node_operation', arguments: { 511 | nodeType: 'nodes-base.httpRequest', 512 | config: { 513 | // Missing required fields 514 | method: 'INVALID_METHOD' 515 | } 516 | } }); 517 | 518 | const validation = JSON.parse((response as any).content[0].text); 519 | expect(validation.valid).toBe(false); 520 | expect(validation.errors).toBeDefined(); 521 | expect(Array.isArray(validation.errors)).toBe(true); 522 | expect(validation.errors.length).toBeGreaterThan(0); 523 | if (validation.errors.length > 0) { 524 | expect(validation.errors[0].message).toBeDefined(); 525 | // Field property might not exist on all error types 526 | if (validation.errors[0].field !== undefined) { 527 | expect(validation.errors[0].field).toBeDefined(); 528 | } 529 | } 530 | }); 531 | }); 532 | }); ``` -------------------------------------------------------------------------------- /src/scripts/fetch-templates.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | import { createDatabaseAdapter } from '../database/database-adapter'; 3 | import { TemplateService } from '../templates/template-service'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | import * as zlib from 'zlib'; 7 | import * as dotenv from 'dotenv'; 8 | import type { MetadataRequest } from '../templates/metadata-generator'; 9 | 10 | // Load environment variables 11 | dotenv.config(); 12 | 13 | /** 14 | * Extract node configurations from a template workflow 15 | */ 16 | function extractNodeConfigs( 17 | templateId: number, 18 | templateName: string, 19 | templateViews: number, 20 | workflowCompressed: string, 21 | metadata: any 22 | ): Array<{ 23 | node_type: string; 24 | template_id: number; 25 | template_name: string; 26 | template_views: number; 27 | node_name: string; 28 | parameters_json: string; 29 | credentials_json: string | null; 30 | has_credentials: number; 31 | has_expressions: number; 32 | complexity: string; 33 | use_cases: string; 34 | }> { 35 | try { 36 | // Decompress workflow 37 | const decompressed = zlib.gunzipSync(Buffer.from(workflowCompressed, 'base64')); 38 | const workflow = JSON.parse(decompressed.toString('utf-8')); 39 | 40 | const configs: any[] = []; 41 | 42 | for (const node of workflow.nodes || []) { 43 | // Skip UI-only nodes (sticky notes, etc.) 44 | if (node.type.includes('stickyNote') || !node.parameters) { 45 | continue; 46 | } 47 | 48 | configs.push({ 49 | node_type: node.type, 50 | template_id: templateId, 51 | template_name: templateName, 52 | template_views: templateViews, 53 | node_name: node.name, 54 | parameters_json: JSON.stringify(node.parameters), 55 | credentials_json: node.credentials ? JSON.stringify(node.credentials) : null, 56 | has_credentials: node.credentials ? 1 : 0, 57 | has_expressions: detectExpressions(node.parameters) ? 1 : 0, 58 | complexity: metadata?.complexity || 'medium', 59 | use_cases: JSON.stringify(metadata?.use_cases || []) 60 | }); 61 | } 62 | 63 | return configs; 64 | } catch (error) { 65 | console.error(`Error extracting configs from template ${templateId}:`, error); 66 | return []; 67 | } 68 | } 69 | 70 | /** 71 | * Detect n8n expressions in parameters 72 | */ 73 | function detectExpressions(params: any): boolean { 74 | if (!params) return false; 75 | const json = JSON.stringify(params); 76 | return json.includes('={{') || json.includes('$json') || json.includes('$node'); 77 | } 78 | 79 | /** 80 | * Insert extracted configs into database and rank them 81 | */ 82 | function insertAndRankConfigs(db: any, configs: any[]) { 83 | if (configs.length === 0) { 84 | console.log('No configs to insert'); 85 | return; 86 | } 87 | 88 | // Clear old configs for these templates 89 | const templateIds = [...new Set(configs.map(c => c.template_id))]; 90 | const placeholders = templateIds.map(() => '?').join(','); 91 | db.prepare(`DELETE FROM template_node_configs WHERE template_id IN (${placeholders})`).run(...templateIds); 92 | 93 | // Insert new configs 94 | const insertStmt = db.prepare(` 95 | INSERT INTO template_node_configs ( 96 | node_type, template_id, template_name, template_views, 97 | node_name, parameters_json, credentials_json, 98 | has_credentials, has_expressions, complexity, use_cases 99 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 100 | `); 101 | 102 | for (const config of configs) { 103 | insertStmt.run( 104 | config.node_type, 105 | config.template_id, 106 | config.template_name, 107 | config.template_views, 108 | config.node_name, 109 | config.parameters_json, 110 | config.credentials_json, 111 | config.has_credentials, 112 | config.has_expressions, 113 | config.complexity, 114 | config.use_cases 115 | ); 116 | } 117 | 118 | // Rank configs per node_type by template popularity 119 | db.exec(` 120 | UPDATE template_node_configs 121 | SET rank = ( 122 | SELECT COUNT(*) + 1 123 | FROM template_node_configs AS t2 124 | WHERE t2.node_type = template_node_configs.node_type 125 | AND t2.template_views > template_node_configs.template_views 126 | ) 127 | `); 128 | 129 | // Keep only top 10 per node_type 130 | db.exec(` 131 | DELETE FROM template_node_configs 132 | WHERE id NOT IN ( 133 | SELECT id FROM template_node_configs 134 | WHERE rank <= 10 135 | ORDER BY node_type, rank 136 | ) 137 | `); 138 | 139 | console.log(`✅ Extracted and ranked ${configs.length} node configurations`); 140 | } 141 | 142 | /** 143 | * Extract node configurations from existing templates 144 | */ 145 | async function extractTemplateConfigs(db: any, service: TemplateService) { 146 | console.log('📦 Extracting node configurations from templates...'); 147 | const repository = (service as any).repository; 148 | const allTemplates = repository.getAllTemplates(); 149 | 150 | const allConfigs: any[] = []; 151 | let configsExtracted = 0; 152 | 153 | for (const template of allTemplates) { 154 | if (template.workflow_json_compressed) { 155 | const metadata = template.metadata_json ? JSON.parse(template.metadata_json) : null; 156 | const configs = extractNodeConfigs( 157 | template.id, 158 | template.name, 159 | template.views, 160 | template.workflow_json_compressed, 161 | metadata 162 | ); 163 | allConfigs.push(...configs); 164 | configsExtracted += configs.length; 165 | } 166 | } 167 | 168 | if (allConfigs.length > 0) { 169 | insertAndRankConfigs(db, allConfigs); 170 | 171 | // Show stats 172 | const configStats = db.prepare(` 173 | SELECT 174 | COUNT(DISTINCT node_type) as node_types, 175 | COUNT(*) as total_configs, 176 | AVG(rank) as avg_rank 177 | FROM template_node_configs 178 | `).get() as any; 179 | 180 | console.log(`📊 Node config stats:`); 181 | console.log(` - Unique node types: ${configStats.node_types}`); 182 | console.log(` - Total configs stored: ${configStats.total_configs}`); 183 | console.log(` - Average rank: ${configStats.avg_rank?.toFixed(1) || 'N/A'}`); 184 | } else { 185 | console.log('⚠️ No node configurations extracted'); 186 | } 187 | } 188 | 189 | async function fetchTemplates( 190 | mode: 'rebuild' | 'update' = 'rebuild', 191 | generateMetadata: boolean = false, 192 | metadataOnly: boolean = false, 193 | extractOnly: boolean = false 194 | ) { 195 | // If extract-only mode, skip template fetching and only extract configs 196 | if (extractOnly) { 197 | console.log('📦 Extract-only mode: Extracting node configurations from existing templates...\n'); 198 | 199 | const db = await createDatabaseAdapter('./data/nodes.db'); 200 | 201 | // Ensure template_node_configs table exists 202 | try { 203 | const tableExists = db.prepare(` 204 | SELECT name FROM sqlite_master 205 | WHERE type='table' AND name='template_node_configs' 206 | `).get(); 207 | 208 | if (!tableExists) { 209 | console.log('📋 Creating template_node_configs table...'); 210 | const migrationPath = path.join(__dirname, '../../src/database/migrations/add-template-node-configs.sql'); 211 | const migration = fs.readFileSync(migrationPath, 'utf8'); 212 | db.exec(migration); 213 | console.log('✅ Table created successfully\n'); 214 | } 215 | } catch (error) { 216 | console.error('❌ Error checking/creating template_node_configs table:', error); 217 | if ('close' in db && typeof db.close === 'function') { 218 | db.close(); 219 | } 220 | process.exit(1); 221 | } 222 | 223 | const service = new TemplateService(db); 224 | 225 | await extractTemplateConfigs(db, service); 226 | 227 | if ('close' in db && typeof db.close === 'function') { 228 | db.close(); 229 | } 230 | return; 231 | } 232 | 233 | // If metadata-only mode, skip template fetching entirely 234 | if (metadataOnly) { 235 | console.log('🤖 Metadata-only mode: Generating metadata for existing templates...\n'); 236 | 237 | if (!process.env.OPENAI_API_KEY) { 238 | console.error('❌ OPENAI_API_KEY not set in environment'); 239 | process.exit(1); 240 | } 241 | 242 | const db = await createDatabaseAdapter('./data/nodes.db'); 243 | const service = new TemplateService(db); 244 | 245 | await generateTemplateMetadata(db, service); 246 | 247 | if ('close' in db && typeof db.close === 'function') { 248 | db.close(); 249 | } 250 | return; 251 | } 252 | 253 | const modeEmoji = mode === 'rebuild' ? '🔄' : '⬆️'; 254 | const modeText = mode === 'rebuild' ? 'Rebuilding' : 'Updating'; 255 | console.log(`${modeEmoji} ${modeText} n8n workflow templates...\n`); 256 | 257 | if (generateMetadata) { 258 | console.log('🤖 Metadata generation enabled (using OpenAI)\n'); 259 | } 260 | 261 | // Ensure data directory exists 262 | const dataDir = './data'; 263 | if (!fs.existsSync(dataDir)) { 264 | fs.mkdirSync(dataDir, { recursive: true }); 265 | } 266 | 267 | // Initialize database 268 | const db = await createDatabaseAdapter('./data/nodes.db'); 269 | 270 | // Handle database schema based on mode 271 | if (mode === 'rebuild') { 272 | try { 273 | // Drop existing tables in rebuild mode 274 | db.exec('DROP TABLE IF EXISTS templates'); 275 | db.exec('DROP TABLE IF EXISTS templates_fts'); 276 | console.log('🗑️ Dropped existing templates tables (rebuild mode)\n'); 277 | 278 | // Apply fresh schema 279 | const schema = fs.readFileSync(path.join(__dirname, '../../src/database/schema.sql'), 'utf8'); 280 | db.exec(schema); 281 | console.log('📋 Applied database schema\n'); 282 | } catch (error) { 283 | console.error('❌ Error setting up database schema:', error); 284 | throw error; 285 | } 286 | } else { 287 | console.log('📊 Update mode: Keeping existing templates and schema\n'); 288 | 289 | // In update mode, only ensure new columns exist (for migration) 290 | try { 291 | // Check if metadata columns exist, add them if not (migration support) 292 | const columns = db.prepare("PRAGMA table_info(templates)").all() as any[]; 293 | const hasMetadataColumn = columns.some((col: any) => col.name === 'metadata_json'); 294 | 295 | if (!hasMetadataColumn) { 296 | console.log('📋 Adding metadata columns to existing schema...'); 297 | db.exec(` 298 | ALTER TABLE templates ADD COLUMN metadata_json TEXT; 299 | ALTER TABLE templates ADD COLUMN metadata_generated_at DATETIME; 300 | `); 301 | console.log('✅ Metadata columns added\n'); 302 | } 303 | } catch (error) { 304 | // Columns might already exist, that's fine 305 | console.log('📋 Schema is up to date\n'); 306 | } 307 | } 308 | 309 | // FTS5 initialization is handled by TemplateRepository 310 | // No need to duplicate the logic here 311 | 312 | // Create service 313 | const service = new TemplateService(db); 314 | 315 | // Progress tracking 316 | let lastMessage = ''; 317 | const startTime = Date.now(); 318 | 319 | try { 320 | await service.fetchAndUpdateTemplates((message, current, total) => { 321 | // Clear previous line 322 | if (lastMessage) { 323 | process.stdout.write('\r' + ' '.repeat(lastMessage.length) + '\r'); 324 | } 325 | 326 | const progress = total > 0 ? Math.round((current / total) * 100) : 0; 327 | lastMessage = `📊 ${message}: ${current}/${total} (${progress}%)`; 328 | process.stdout.write(lastMessage); 329 | }, mode); // Pass the mode parameter! 330 | 331 | console.log('\n'); // New line after progress 332 | 333 | // Get stats 334 | const stats = await service.getTemplateStats(); 335 | const elapsed = Math.round((Date.now() - startTime) / 1000); 336 | 337 | console.log('✅ Template fetch complete!\n'); 338 | console.log('📈 Statistics:'); 339 | console.log(` - Total templates: ${stats.totalTemplates}`); 340 | console.log(` - Average views: ${stats.averageViews}`); 341 | console.log(` - Time elapsed: ${elapsed} seconds`); 342 | console.log('\n🔝 Top used nodes:'); 343 | 344 | stats.topUsedNodes.forEach((node: any, index: number) => { 345 | console.log(` ${index + 1}. ${node.node} (${node.count} templates)`); 346 | }); 347 | 348 | // Extract node configurations from templates 349 | console.log(''); 350 | await extractTemplateConfigs(db, service); 351 | 352 | // Generate metadata if requested 353 | if (generateMetadata && process.env.OPENAI_API_KEY) { 354 | console.log('\n🤖 Generating metadata for templates...'); 355 | await generateTemplateMetadata(db, service); 356 | } else if (generateMetadata && !process.env.OPENAI_API_KEY) { 357 | console.log('\n⚠️ Metadata generation requested but OPENAI_API_KEY not set'); 358 | } 359 | 360 | } catch (error) { 361 | console.error('\n❌ Error fetching templates:', error); 362 | process.exit(1); 363 | } 364 | 365 | // Close database 366 | if ('close' in db && typeof db.close === 'function') { 367 | db.close(); 368 | } 369 | } 370 | 371 | // Generate metadata for templates using OpenAI 372 | async function generateTemplateMetadata(db: any, service: TemplateService) { 373 | try { 374 | const { BatchProcessor } = await import('../templates/batch-processor'); 375 | const repository = (service as any).repository; 376 | 377 | // Get templates without metadata (0 = no limit) 378 | const limit = parseInt(process.env.METADATA_LIMIT || '0'); 379 | const templatesWithoutMetadata = limit > 0 380 | ? repository.getTemplatesWithoutMetadata(limit) 381 | : repository.getTemplatesWithoutMetadata(999999); // Get all 382 | 383 | if (templatesWithoutMetadata.length === 0) { 384 | console.log('✅ All templates already have metadata'); 385 | return; 386 | } 387 | 388 | console.log(`Found ${templatesWithoutMetadata.length} templates without metadata`); 389 | 390 | // Create batch processor 391 | const batchSize = parseInt(process.env.OPENAI_BATCH_SIZE || '50'); 392 | console.log(`Processing in batches of ${batchSize} templates each`); 393 | 394 | // Warn if batch size is very large 395 | if (batchSize > 100) { 396 | console.log(`⚠️ Large batch size (${batchSize}) may take longer to process`); 397 | console.log(` Consider using OPENAI_BATCH_SIZE=50 for faster results`); 398 | } 399 | 400 | const processor = new BatchProcessor({ 401 | apiKey: process.env.OPENAI_API_KEY!, 402 | model: process.env.OPENAI_MODEL || 'gpt-4o-mini', 403 | batchSize: batchSize, 404 | outputDir: './temp/batch' 405 | }); 406 | 407 | // Prepare metadata requests 408 | const requests: MetadataRequest[] = templatesWithoutMetadata.map((t: any) => { 409 | let workflow = undefined; 410 | try { 411 | if (t.workflow_json_compressed) { 412 | const decompressed = zlib.gunzipSync(Buffer.from(t.workflow_json_compressed, 'base64')); 413 | workflow = JSON.parse(decompressed.toString()); 414 | } else if (t.workflow_json) { 415 | workflow = JSON.parse(t.workflow_json); 416 | } 417 | } catch (error) { 418 | console.warn(`Failed to parse workflow for template ${t.id}:`, error); 419 | } 420 | 421 | // Parse nodes_used safely 422 | let nodes: string[] = []; 423 | try { 424 | if (t.nodes_used) { 425 | nodes = JSON.parse(t.nodes_used); 426 | // Ensure it's an array 427 | if (!Array.isArray(nodes)) { 428 | console.warn(`Template ${t.id} has invalid nodes_used (not an array), using empty array`); 429 | nodes = []; 430 | } 431 | } 432 | } catch (error) { 433 | console.warn(`Failed to parse nodes_used for template ${t.id}:`, error); 434 | nodes = []; 435 | } 436 | 437 | return { 438 | templateId: t.id, 439 | name: t.name, 440 | description: t.description, 441 | nodes: nodes, 442 | workflow 443 | }; 444 | }); 445 | 446 | // Process in batches 447 | const results = await processor.processTemplates(requests, (message, current, total) => { 448 | process.stdout.write(`\r📊 ${message}: ${current}/${total}`); 449 | }); 450 | 451 | console.log('\n'); 452 | 453 | // Update database with metadata 454 | const metadataMap = new Map(); 455 | for (const [templateId, result] of results) { 456 | if (!result.error) { 457 | metadataMap.set(templateId, result.metadata); 458 | } 459 | } 460 | 461 | if (metadataMap.size > 0) { 462 | repository.batchUpdateMetadata(metadataMap); 463 | console.log(`✅ Updated metadata for ${metadataMap.size} templates`); 464 | } 465 | 466 | // Show stats 467 | const stats = repository.getMetadataStats(); 468 | console.log('\n📈 Metadata Statistics:'); 469 | console.log(` - Total templates: ${stats.total}`); 470 | console.log(` - With metadata: ${stats.withMetadata}`); 471 | console.log(` - Without metadata: ${stats.withoutMetadata}`); 472 | console.log(` - Outdated (>30 days): ${stats.outdated}`); 473 | } catch (error) { 474 | console.error('\n❌ Error generating metadata:', error); 475 | } 476 | } 477 | 478 | // Parse command line arguments 479 | function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, metadataOnly: boolean, extractOnly: boolean } { 480 | const args = process.argv.slice(2); 481 | 482 | let mode: 'rebuild' | 'update' = 'rebuild'; 483 | let generateMetadata = false; 484 | let metadataOnly = false; 485 | let extractOnly = false; 486 | 487 | // Check for --mode flag 488 | const modeIndex = args.findIndex(arg => arg.startsWith('--mode')); 489 | if (modeIndex !== -1) { 490 | const modeArg = args[modeIndex]; 491 | const modeValue = modeArg.includes('=') ? modeArg.split('=')[1] : args[modeIndex + 1]; 492 | 493 | if (modeValue === 'update') { 494 | mode = 'update'; 495 | } 496 | } 497 | 498 | // Check for --update flag as shorthand 499 | if (args.includes('--update')) { 500 | mode = 'update'; 501 | } 502 | 503 | // Check for --generate-metadata flag 504 | if (args.includes('--generate-metadata') || args.includes('--metadata')) { 505 | generateMetadata = true; 506 | } 507 | 508 | // Check for --metadata-only flag 509 | if (args.includes('--metadata-only')) { 510 | metadataOnly = true; 511 | } 512 | 513 | // Check for --extract-only flag 514 | if (args.includes('--extract-only') || args.includes('--extract')) { 515 | extractOnly = true; 516 | } 517 | 518 | // Show help if requested 519 | if (args.includes('--help') || args.includes('-h')) { 520 | console.log('Usage: npm run fetch:templates [options]\n'); 521 | console.log('Options:'); 522 | console.log(' --mode=rebuild|update Rebuild from scratch or update existing (default: rebuild)'); 523 | console.log(' --update Shorthand for --mode=update'); 524 | console.log(' --generate-metadata Generate AI metadata after fetching templates'); 525 | console.log(' --metadata Shorthand for --generate-metadata'); 526 | console.log(' --metadata-only Only generate metadata, skip template fetching'); 527 | console.log(' --extract-only Only extract node configs, skip template fetching'); 528 | console.log(' --extract Shorthand for --extract-only'); 529 | console.log(' --help, -h Show this help message'); 530 | process.exit(0); 531 | } 532 | 533 | return { mode, generateMetadata, metadataOnly, extractOnly }; 534 | } 535 | 536 | // Run if called directly 537 | if (require.main === module) { 538 | const { mode, generateMetadata, metadataOnly, extractOnly } = parseArgs(); 539 | fetchTemplates(mode, generateMetadata, metadataOnly, extractOnly).catch(console.error); 540 | } 541 | 542 | export { fetchTemplates }; ``` -------------------------------------------------------------------------------- /tests/unit/database/node-repository-outputs.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { NodeRepository } from '@/database/node-repository'; 3 | import { DatabaseAdapter } from '@/database/database-adapter'; 4 | import { ParsedNode } from '@/parsers/node-parser'; 5 | 6 | describe('NodeRepository - Outputs Handling', () => { 7 | let repository: NodeRepository; 8 | let mockDb: DatabaseAdapter; 9 | let mockStatement: any; 10 | 11 | beforeEach(() => { 12 | mockStatement = { 13 | run: vi.fn(), 14 | get: vi.fn(), 15 | all: vi.fn() 16 | }; 17 | 18 | mockDb = { 19 | prepare: vi.fn().mockReturnValue(mockStatement), 20 | transaction: vi.fn(), 21 | exec: vi.fn(), 22 | close: vi.fn(), 23 | pragma: vi.fn() 24 | } as any; 25 | 26 | repository = new NodeRepository(mockDb); 27 | }); 28 | 29 | describe('saveNode with outputs', () => { 30 | it('should save node with outputs and outputNames correctly', () => { 31 | const outputs = [ 32 | { displayName: 'Done', description: 'Final results when loop completes' }, 33 | { displayName: 'Loop', description: 'Current batch data during iteration' } 34 | ]; 35 | const outputNames = ['done', 'loop']; 36 | 37 | const node: ParsedNode = { 38 | style: 'programmatic', 39 | nodeType: 'nodes-base.splitInBatches', 40 | displayName: 'Split In Batches', 41 | description: 'Split data into batches', 42 | category: 'transform', 43 | properties: [], 44 | credentials: [], 45 | isAITool: false, 46 | isTrigger: false, 47 | isWebhook: false, 48 | operations: [], 49 | version: '3', 50 | isVersioned: false, 51 | packageName: 'n8n-nodes-base', 52 | outputs, 53 | outputNames 54 | }; 55 | 56 | repository.saveNode(node); 57 | 58 | expect(mockDb.prepare).toHaveBeenCalledWith(` 59 | INSERT OR REPLACE INTO nodes ( 60 | node_type, package_name, display_name, description, 61 | category, development_style, is_ai_tool, is_trigger, 62 | is_webhook, is_versioned, version, documentation, 63 | properties_schema, operations, credentials_required, 64 | outputs, output_names 65 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 66 | `); 67 | 68 | expect(mockStatement.run).toHaveBeenCalledWith( 69 | 'nodes-base.splitInBatches', 70 | 'n8n-nodes-base', 71 | 'Split In Batches', 72 | 'Split data into batches', 73 | 'transform', 74 | 'programmatic', 75 | 0, // false 76 | 0, // false 77 | 0, // false 78 | 0, // false 79 | '3', 80 | null, // documentation 81 | JSON.stringify([], null, 2), // properties 82 | JSON.stringify([], null, 2), // operations 83 | JSON.stringify([], null, 2), // credentials 84 | JSON.stringify(outputs, null, 2), // outputs 85 | JSON.stringify(outputNames, null, 2) // output_names 86 | ); 87 | }); 88 | 89 | it('should save node with only outputs (no outputNames)', () => { 90 | const outputs = [ 91 | { displayName: 'True', description: 'Items that match condition' }, 92 | { displayName: 'False', description: 'Items that do not match condition' } 93 | ]; 94 | 95 | const node: ParsedNode = { 96 | style: 'programmatic', 97 | nodeType: 'nodes-base.if', 98 | displayName: 'IF', 99 | description: 'Route items based on conditions', 100 | category: 'transform', 101 | properties: [], 102 | credentials: [], 103 | isAITool: false, 104 | isTrigger: false, 105 | isWebhook: false, 106 | operations: [], 107 | version: '2', 108 | isVersioned: false, 109 | packageName: 'n8n-nodes-base', 110 | outputs 111 | // no outputNames 112 | }; 113 | 114 | repository.saveNode(node); 115 | 116 | const callArgs = mockStatement.run.mock.calls[0]; 117 | expect(callArgs[15]).toBe(JSON.stringify(outputs, null, 2)); // outputs 118 | expect(callArgs[16]).toBe(null); // output_names should be null 119 | }); 120 | 121 | it('should save node with only outputNames (no outputs)', () => { 122 | const outputNames = ['main', 'error']; 123 | 124 | const node: ParsedNode = { 125 | style: 'programmatic', 126 | nodeType: 'nodes-base.customNode', 127 | displayName: 'Custom Node', 128 | description: 'Custom node with output names only', 129 | category: 'transform', 130 | properties: [], 131 | credentials: [], 132 | isAITool: false, 133 | isTrigger: false, 134 | isWebhook: false, 135 | operations: [], 136 | version: '1', 137 | isVersioned: false, 138 | packageName: 'n8n-nodes-base', 139 | outputNames 140 | // no outputs 141 | }; 142 | 143 | repository.saveNode(node); 144 | 145 | const callArgs = mockStatement.run.mock.calls[0]; 146 | expect(callArgs[15]).toBe(null); // outputs should be null 147 | expect(callArgs[16]).toBe(JSON.stringify(outputNames, null, 2)); // output_names 148 | }); 149 | 150 | it('should save node without outputs or outputNames', () => { 151 | const node: ParsedNode = { 152 | style: 'programmatic', 153 | nodeType: 'nodes-base.httpRequest', 154 | displayName: 'HTTP Request', 155 | description: 'Make HTTP requests', 156 | category: 'input', 157 | properties: [], 158 | credentials: [], 159 | isAITool: false, 160 | isTrigger: false, 161 | isWebhook: false, 162 | operations: [], 163 | version: '4', 164 | isVersioned: false, 165 | packageName: 'n8n-nodes-base' 166 | // no outputs or outputNames 167 | }; 168 | 169 | repository.saveNode(node); 170 | 171 | const callArgs = mockStatement.run.mock.calls[0]; 172 | expect(callArgs[15]).toBe(null); // outputs should be null 173 | expect(callArgs[16]).toBe(null); // output_names should be null 174 | }); 175 | 176 | it('should handle empty outputs and outputNames arrays', () => { 177 | const node: ParsedNode = { 178 | style: 'programmatic', 179 | nodeType: 'nodes-base.emptyNode', 180 | displayName: 'Empty Node', 181 | description: 'Node with empty outputs', 182 | category: 'misc', 183 | properties: [], 184 | credentials: [], 185 | isAITool: false, 186 | isTrigger: false, 187 | isWebhook: false, 188 | operations: [], 189 | version: '1', 190 | isVersioned: false, 191 | packageName: 'n8n-nodes-base', 192 | outputs: [], 193 | outputNames: [] 194 | }; 195 | 196 | repository.saveNode(node); 197 | 198 | const callArgs = mockStatement.run.mock.calls[0]; 199 | expect(callArgs[15]).toBe(JSON.stringify([], null, 2)); // outputs 200 | expect(callArgs[16]).toBe(JSON.stringify([], null, 2)); // output_names 201 | }); 202 | }); 203 | 204 | describe('getNode with outputs', () => { 205 | it('should retrieve node with outputs and outputNames correctly', () => { 206 | const outputs = [ 207 | { displayName: 'Done', description: 'Final results when loop completes' }, 208 | { displayName: 'Loop', description: 'Current batch data during iteration' } 209 | ]; 210 | const outputNames = ['done', 'loop']; 211 | 212 | const mockRow = { 213 | node_type: 'nodes-base.splitInBatches', 214 | display_name: 'Split In Batches', 215 | description: 'Split data into batches', 216 | category: 'transform', 217 | development_style: 'programmatic', 218 | package_name: 'n8n-nodes-base', 219 | is_ai_tool: 0, 220 | is_trigger: 0, 221 | is_webhook: 0, 222 | is_versioned: 0, 223 | version: '3', 224 | properties_schema: JSON.stringify([]), 225 | operations: JSON.stringify([]), 226 | credentials_required: JSON.stringify([]), 227 | documentation: null, 228 | outputs: JSON.stringify(outputs), 229 | output_names: JSON.stringify(outputNames) 230 | }; 231 | 232 | mockStatement.get.mockReturnValue(mockRow); 233 | 234 | const result = repository.getNode('nodes-base.splitInBatches'); 235 | 236 | expect(result).toEqual({ 237 | nodeType: 'nodes-base.splitInBatches', 238 | displayName: 'Split In Batches', 239 | description: 'Split data into batches', 240 | category: 'transform', 241 | developmentStyle: 'programmatic', 242 | package: 'n8n-nodes-base', 243 | isAITool: false, 244 | isTrigger: false, 245 | isWebhook: false, 246 | isVersioned: false, 247 | version: '3', 248 | properties: [], 249 | operations: [], 250 | credentials: [], 251 | hasDocumentation: false, 252 | outputs, 253 | outputNames 254 | }); 255 | }); 256 | 257 | it('should retrieve node with only outputs (null outputNames)', () => { 258 | const outputs = [ 259 | { displayName: 'True', description: 'Items that match condition' } 260 | ]; 261 | 262 | const mockRow = { 263 | node_type: 'nodes-base.if', 264 | display_name: 'IF', 265 | description: 'Route items', 266 | category: 'transform', 267 | development_style: 'programmatic', 268 | package_name: 'n8n-nodes-base', 269 | is_ai_tool: 0, 270 | is_trigger: 0, 271 | is_webhook: 0, 272 | is_versioned: 0, 273 | version: '2', 274 | properties_schema: JSON.stringify([]), 275 | operations: JSON.stringify([]), 276 | credentials_required: JSON.stringify([]), 277 | documentation: null, 278 | outputs: JSON.stringify(outputs), 279 | output_names: null 280 | }; 281 | 282 | mockStatement.get.mockReturnValue(mockRow); 283 | 284 | const result = repository.getNode('nodes-base.if'); 285 | 286 | expect(result.outputs).toEqual(outputs); 287 | expect(result.outputNames).toBe(null); 288 | }); 289 | 290 | it('should retrieve node with only outputNames (null outputs)', () => { 291 | const outputNames = ['main']; 292 | 293 | const mockRow = { 294 | node_type: 'nodes-base.customNode', 295 | display_name: 'Custom Node', 296 | description: 'Custom node', 297 | category: 'misc', 298 | development_style: 'programmatic', 299 | package_name: 'n8n-nodes-base', 300 | is_ai_tool: 0, 301 | is_trigger: 0, 302 | is_webhook: 0, 303 | is_versioned: 0, 304 | version: '1', 305 | properties_schema: JSON.stringify([]), 306 | operations: JSON.stringify([]), 307 | credentials_required: JSON.stringify([]), 308 | documentation: null, 309 | outputs: null, 310 | output_names: JSON.stringify(outputNames) 311 | }; 312 | 313 | mockStatement.get.mockReturnValue(mockRow); 314 | 315 | const result = repository.getNode('nodes-base.customNode'); 316 | 317 | expect(result.outputs).toBe(null); 318 | expect(result.outputNames).toEqual(outputNames); 319 | }); 320 | 321 | it('should retrieve node without outputs or outputNames', () => { 322 | const mockRow = { 323 | node_type: 'nodes-base.httpRequest', 324 | display_name: 'HTTP Request', 325 | description: 'Make HTTP requests', 326 | category: 'input', 327 | development_style: 'programmatic', 328 | package_name: 'n8n-nodes-base', 329 | is_ai_tool: 0, 330 | is_trigger: 0, 331 | is_webhook: 0, 332 | is_versioned: 0, 333 | version: '4', 334 | properties_schema: JSON.stringify([]), 335 | operations: JSON.stringify([]), 336 | credentials_required: JSON.stringify([]), 337 | documentation: null, 338 | outputs: null, 339 | output_names: null 340 | }; 341 | 342 | mockStatement.get.mockReturnValue(mockRow); 343 | 344 | const result = repository.getNode('nodes-base.httpRequest'); 345 | 346 | expect(result.outputs).toBe(null); 347 | expect(result.outputNames).toBe(null); 348 | }); 349 | 350 | it('should handle malformed JSON gracefully', () => { 351 | const mockRow = { 352 | node_type: 'nodes-base.malformed', 353 | display_name: 'Malformed Node', 354 | description: 'Node with malformed JSON', 355 | category: 'misc', 356 | development_style: 'programmatic', 357 | package_name: 'n8n-nodes-base', 358 | is_ai_tool: 0, 359 | is_trigger: 0, 360 | is_webhook: 0, 361 | is_versioned: 0, 362 | version: '1', 363 | properties_schema: JSON.stringify([]), 364 | operations: JSON.stringify([]), 365 | credentials_required: JSON.stringify([]), 366 | documentation: null, 367 | outputs: '{invalid json}', 368 | output_names: '[invalid, json' 369 | }; 370 | 371 | mockStatement.get.mockReturnValue(mockRow); 372 | 373 | const result = repository.getNode('nodes-base.malformed'); 374 | 375 | // Should use default values when JSON parsing fails 376 | expect(result.outputs).toBe(null); 377 | expect(result.outputNames).toBe(null); 378 | }); 379 | 380 | it('should return null for non-existent node', () => { 381 | mockStatement.get.mockReturnValue(null); 382 | 383 | const result = repository.getNode('nodes-base.nonExistent'); 384 | 385 | expect(result).toBe(null); 386 | }); 387 | 388 | it('should handle SplitInBatches counterintuitive output order correctly', () => { 389 | // Test that the output order is preserved: done=0, loop=1 390 | const outputs = [ 391 | { displayName: 'Done', description: 'Final results when loop completes', index: 0 }, 392 | { displayName: 'Loop', description: 'Current batch data during iteration', index: 1 } 393 | ]; 394 | const outputNames = ['done', 'loop']; 395 | 396 | const mockRow = { 397 | node_type: 'nodes-base.splitInBatches', 398 | display_name: 'Split In Batches', 399 | description: 'Split data into batches', 400 | category: 'transform', 401 | development_style: 'programmatic', 402 | package_name: 'n8n-nodes-base', 403 | is_ai_tool: 0, 404 | is_trigger: 0, 405 | is_webhook: 0, 406 | is_versioned: 0, 407 | version: '3', 408 | properties_schema: JSON.stringify([]), 409 | operations: JSON.stringify([]), 410 | credentials_required: JSON.stringify([]), 411 | documentation: null, 412 | outputs: JSON.stringify(outputs), 413 | output_names: JSON.stringify(outputNames) 414 | }; 415 | 416 | mockStatement.get.mockReturnValue(mockRow); 417 | 418 | const result = repository.getNode('nodes-base.splitInBatches'); 419 | 420 | // Verify order is preserved 421 | expect(result.outputs[0].displayName).toBe('Done'); 422 | expect(result.outputs[1].displayName).toBe('Loop'); 423 | expect(result.outputNames[0]).toBe('done'); 424 | expect(result.outputNames[1]).toBe('loop'); 425 | }); 426 | }); 427 | 428 | describe('parseNodeRow with outputs', () => { 429 | it('should parse node row with outputs correctly using parseNodeRow', () => { 430 | const outputs = [{ displayName: 'Output' }]; 431 | const outputNames = ['main']; 432 | 433 | const mockRow = { 434 | node_type: 'nodes-base.test', 435 | display_name: 'Test', 436 | description: 'Test node', 437 | category: 'misc', 438 | development_style: 'programmatic', 439 | package_name: 'n8n-nodes-base', 440 | is_ai_tool: 0, 441 | is_trigger: 0, 442 | is_webhook: 0, 443 | is_versioned: 0, 444 | version: '1', 445 | properties_schema: JSON.stringify([]), 446 | operations: JSON.stringify([]), 447 | credentials_required: JSON.stringify([]), 448 | documentation: null, 449 | outputs: JSON.stringify(outputs), 450 | output_names: JSON.stringify(outputNames) 451 | }; 452 | 453 | mockStatement.all.mockReturnValue([mockRow]); 454 | 455 | const results = repository.getAllNodes(1); 456 | 457 | expect(results[0].outputs).toEqual(outputs); 458 | expect(results[0].outputNames).toEqual(outputNames); 459 | }); 460 | 461 | it('should handle empty string as null for outputs', () => { 462 | const mockRow = { 463 | node_type: 'nodes-base.empty', 464 | display_name: 'Empty', 465 | description: 'Empty node', 466 | category: 'misc', 467 | development_style: 'programmatic', 468 | package_name: 'n8n-nodes-base', 469 | is_ai_tool: 0, 470 | is_trigger: 0, 471 | is_webhook: 0, 472 | is_versioned: 0, 473 | version: '1', 474 | properties_schema: JSON.stringify([]), 475 | operations: JSON.stringify([]), 476 | credentials_required: JSON.stringify([]), 477 | documentation: null, 478 | outputs: '', // empty string 479 | output_names: '' // empty string 480 | }; 481 | 482 | mockStatement.all.mockReturnValue([mockRow]); 483 | 484 | const results = repository.getAllNodes(1); 485 | 486 | // Empty strings should be treated as null since they fail JSON parsing 487 | expect(results[0].outputs).toBe(null); 488 | expect(results[0].outputNames).toBe(null); 489 | }); 490 | }); 491 | 492 | describe('complex output structures', () => { 493 | it('should handle complex output objects with metadata', () => { 494 | const complexOutputs = [ 495 | { 496 | displayName: 'Done', 497 | name: 'done', 498 | type: 'main', 499 | hint: 'Receives the final data after all batches have been processed', 500 | description: 'Final results when loop completes', 501 | index: 0 502 | }, 503 | { 504 | displayName: 'Loop', 505 | name: 'loop', 506 | type: 'main', 507 | hint: 'Receives the current batch data during each iteration', 508 | description: 'Current batch data during iteration', 509 | index: 1 510 | } 511 | ]; 512 | 513 | const node: ParsedNode = { 514 | style: 'programmatic', 515 | nodeType: 'nodes-base.splitInBatches', 516 | displayName: 'Split In Batches', 517 | description: 'Split data into batches', 518 | category: 'transform', 519 | properties: [], 520 | credentials: [], 521 | isAITool: false, 522 | isTrigger: false, 523 | isWebhook: false, 524 | operations: [], 525 | version: '3', 526 | isVersioned: false, 527 | packageName: 'n8n-nodes-base', 528 | outputs: complexOutputs, 529 | outputNames: ['done', 'loop'] 530 | }; 531 | 532 | repository.saveNode(node); 533 | 534 | // Simulate retrieval 535 | const mockRow = { 536 | node_type: 'nodes-base.splitInBatches', 537 | display_name: 'Split In Batches', 538 | description: 'Split data into batches', 539 | category: 'transform', 540 | development_style: 'programmatic', 541 | package_name: 'n8n-nodes-base', 542 | is_ai_tool: 0, 543 | is_trigger: 0, 544 | is_webhook: 0, 545 | is_versioned: 0, 546 | version: '3', 547 | properties_schema: JSON.stringify([]), 548 | operations: JSON.stringify([]), 549 | credentials_required: JSON.stringify([]), 550 | documentation: null, 551 | outputs: JSON.stringify(complexOutputs), 552 | output_names: JSON.stringify(['done', 'loop']) 553 | }; 554 | 555 | mockStatement.get.mockReturnValue(mockRow); 556 | 557 | const result = repository.getNode('nodes-base.splitInBatches'); 558 | 559 | expect(result.outputs).toEqual(complexOutputs); 560 | expect(result.outputs[0]).toMatchObject({ 561 | displayName: 'Done', 562 | name: 'done', 563 | type: 'main', 564 | hint: 'Receives the final data after all batches have been processed' 565 | }); 566 | }); 567 | }); 568 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/execution-processor.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Execution Processor Service Tests 3 | * 4 | * Comprehensive test coverage for execution filtering and processing 5 | */ 6 | 7 | import { describe, it, expect } from 'vitest'; 8 | import { 9 | generatePreview, 10 | filterExecutionData, 11 | processExecution, 12 | } from '../../../src/services/execution-processor'; 13 | import { 14 | Execution, 15 | ExecutionStatus, 16 | ExecutionFilterOptions, 17 | } from '../../../src/types/n8n-api'; 18 | 19 | /** 20 | * Test data factories 21 | */ 22 | 23 | function createMockExecution(options: { 24 | id?: string; 25 | status?: ExecutionStatus; 26 | nodeData?: Record<string, any>; 27 | hasError?: boolean; 28 | }): Execution { 29 | const { id = 'test-exec-1', status = ExecutionStatus.SUCCESS, nodeData = {}, hasError = false } = options; 30 | 31 | return { 32 | id, 33 | workflowId: 'workflow-1', 34 | status, 35 | mode: 'manual', 36 | finished: true, 37 | startedAt: '2024-01-01T10:00:00.000Z', 38 | stoppedAt: '2024-01-01T10:00:05.000Z', 39 | data: { 40 | resultData: { 41 | runData: nodeData, 42 | error: hasError ? { message: 'Test error' } : undefined, 43 | }, 44 | }, 45 | }; 46 | } 47 | 48 | function createNodeData(itemCount: number, includeError = false) { 49 | const items = Array.from({ length: itemCount }, (_, i) => ({ 50 | json: { 51 | id: i + 1, 52 | name: `Item ${i + 1}`, 53 | value: Math.random() * 100, 54 | nested: { 55 | field1: `value${i}`, 56 | field2: true, 57 | }, 58 | }, 59 | })); 60 | 61 | return [ 62 | { 63 | startTime: Date.now(), 64 | executionTime: 123, 65 | data: { 66 | main: [items], 67 | }, 68 | error: includeError ? { message: 'Node error' } : undefined, 69 | }, 70 | ]; 71 | } 72 | 73 | /** 74 | * Preview Mode Tests 75 | */ 76 | describe('ExecutionProcessor - Preview Mode', () => { 77 | it('should generate preview for empty execution', () => { 78 | const execution = createMockExecution({ nodeData: {} }); 79 | const { preview, recommendation } = generatePreview(execution); 80 | 81 | expect(preview.totalNodes).toBe(0); 82 | expect(preview.executedNodes).toBe(0); 83 | expect(preview.estimatedSizeKB).toBe(0); 84 | expect(recommendation.canFetchFull).toBe(true); 85 | expect(recommendation.suggestedMode).toBe('full'); // Empty execution is safe to fetch in full 86 | }); 87 | 88 | it('should generate preview with accurate item counts', () => { 89 | const execution = createMockExecution({ 90 | nodeData: { 91 | 'HTTP Request': createNodeData(50), 92 | 'Filter': createNodeData(12), 93 | }, 94 | }); 95 | 96 | const { preview } = generatePreview(execution); 97 | 98 | expect(preview.totalNodes).toBe(2); 99 | expect(preview.executedNodes).toBe(2); 100 | expect(preview.nodes['HTTP Request'].itemCounts.output).toBe(50); 101 | expect(preview.nodes['Filter'].itemCounts.output).toBe(12); 102 | }); 103 | 104 | it('should extract data structure from nodes', () => { 105 | const execution = createMockExecution({ 106 | nodeData: { 107 | 'HTTP Request': createNodeData(5), 108 | }, 109 | }); 110 | 111 | const { preview } = generatePreview(execution); 112 | const structure = preview.nodes['HTTP Request'].dataStructure; 113 | 114 | expect(structure).toHaveProperty('json'); 115 | expect(structure.json).toHaveProperty('id'); 116 | expect(structure.json).toHaveProperty('name'); 117 | expect(structure.json).toHaveProperty('nested'); 118 | expect(structure.json.id).toBe('number'); 119 | expect(structure.json.name).toBe('string'); 120 | expect(typeof structure.json.nested).toBe('object'); 121 | }); 122 | 123 | it('should estimate data size', () => { 124 | const execution = createMockExecution({ 125 | nodeData: { 126 | 'HTTP Request': createNodeData(50), 127 | }, 128 | }); 129 | 130 | const { preview } = generatePreview(execution); 131 | 132 | expect(preview.estimatedSizeKB).toBeGreaterThan(0); 133 | expect(preview.nodes['HTTP Request'].estimatedSizeKB).toBeGreaterThan(0); 134 | }); 135 | 136 | it('should detect error status in nodes', () => { 137 | const execution = createMockExecution({ 138 | nodeData: { 139 | 'HTTP Request': createNodeData(5, true), 140 | }, 141 | }); 142 | 143 | const { preview } = generatePreview(execution); 144 | 145 | expect(preview.nodes['HTTP Request'].status).toBe('error'); 146 | expect(preview.nodes['HTTP Request'].error).toBeDefined(); 147 | }); 148 | 149 | it('should recommend full mode for small datasets', () => { 150 | const execution = createMockExecution({ 151 | nodeData: { 152 | 'HTTP Request': createNodeData(5), 153 | }, 154 | }); 155 | 156 | const { recommendation } = generatePreview(execution); 157 | 158 | expect(recommendation.canFetchFull).toBe(true); 159 | expect(recommendation.suggestedMode).toBe('full'); 160 | }); 161 | 162 | it('should recommend filtered mode for large datasets', () => { 163 | const execution = createMockExecution({ 164 | nodeData: { 165 | 'HTTP Request': createNodeData(100), 166 | }, 167 | }); 168 | 169 | const { recommendation } = generatePreview(execution); 170 | 171 | expect(recommendation.canFetchFull).toBe(false); 172 | expect(recommendation.suggestedMode).toBe('filtered'); 173 | expect(recommendation.suggestedItemsLimit).toBeGreaterThan(0); 174 | }); 175 | 176 | it('should recommend summary mode for moderate datasets', () => { 177 | const execution = createMockExecution({ 178 | nodeData: { 179 | 'HTTP Request': createNodeData(30), 180 | }, 181 | }); 182 | 183 | const { recommendation } = generatePreview(execution); 184 | 185 | expect(recommendation.canFetchFull).toBe(false); 186 | expect(recommendation.suggestedMode).toBe('summary'); 187 | }); 188 | }); 189 | 190 | /** 191 | * Filtering Mode Tests 192 | */ 193 | describe('ExecutionProcessor - Filtering', () => { 194 | it('should filter by node names', () => { 195 | const execution = createMockExecution({ 196 | nodeData: { 197 | 'HTTP Request': createNodeData(10), 198 | 'Filter': createNodeData(5), 199 | 'Set': createNodeData(3), 200 | }, 201 | }); 202 | 203 | const options: ExecutionFilterOptions = { 204 | mode: 'filtered', 205 | nodeNames: ['HTTP Request', 'Filter'], 206 | }; 207 | 208 | const result = filterExecutionData(execution, options); 209 | 210 | expect(result.nodes).toHaveProperty('HTTP Request'); 211 | expect(result.nodes).toHaveProperty('Filter'); 212 | expect(result.nodes).not.toHaveProperty('Set'); 213 | expect(result.summary?.executedNodes).toBe(2); 214 | }); 215 | 216 | it('should handle non-existent node names gracefully', () => { 217 | const execution = createMockExecution({ 218 | nodeData: { 219 | 'HTTP Request': createNodeData(10), 220 | }, 221 | }); 222 | 223 | const options: ExecutionFilterOptions = { 224 | mode: 'filtered', 225 | nodeNames: ['NonExistent'], 226 | }; 227 | 228 | const result = filterExecutionData(execution, options); 229 | 230 | expect(Object.keys(result.nodes || {})).toHaveLength(0); 231 | expect(result.summary?.executedNodes).toBe(0); 232 | }); 233 | 234 | it('should limit items to 0 (structure only)', () => { 235 | const execution = createMockExecution({ 236 | nodeData: { 237 | 'HTTP Request': createNodeData(50), 238 | }, 239 | }); 240 | 241 | const options: ExecutionFilterOptions = { 242 | mode: 'filtered', 243 | itemsLimit: 0, 244 | }; 245 | 246 | const result = filterExecutionData(execution, options); 247 | const nodeData = result.nodes?.['HTTP Request']; 248 | 249 | expect(nodeData?.data?.metadata.itemsShown).toBe(0); 250 | expect(nodeData?.data?.metadata.truncated).toBe(true); 251 | expect(nodeData?.data?.metadata.totalItems).toBe(50); 252 | 253 | // Check that we have structure but no actual values 254 | const output = nodeData?.data?.output?.[0]?.[0]; 255 | expect(output).toBeDefined(); 256 | expect(typeof output).toBe('object'); 257 | }); 258 | 259 | it('should limit items to 2 (default)', () => { 260 | const execution = createMockExecution({ 261 | nodeData: { 262 | 'HTTP Request': createNodeData(50), 263 | }, 264 | }); 265 | 266 | const options: ExecutionFilterOptions = { 267 | mode: 'summary', 268 | }; 269 | 270 | const result = filterExecutionData(execution, options); 271 | const nodeData = result.nodes?.['HTTP Request']; 272 | 273 | expect(nodeData?.data?.metadata.itemsShown).toBe(2); 274 | expect(nodeData?.data?.metadata.totalItems).toBe(50); 275 | expect(nodeData?.data?.metadata.truncated).toBe(true); 276 | expect(nodeData?.data?.output?.[0]).toHaveLength(2); 277 | }); 278 | 279 | it('should limit items to custom value', () => { 280 | const execution = createMockExecution({ 281 | nodeData: { 282 | 'HTTP Request': createNodeData(50), 283 | }, 284 | }); 285 | 286 | const options: ExecutionFilterOptions = { 287 | mode: 'filtered', 288 | itemsLimit: 5, 289 | }; 290 | 291 | const result = filterExecutionData(execution, options); 292 | const nodeData = result.nodes?.['HTTP Request']; 293 | 294 | expect(nodeData?.data?.metadata.itemsShown).toBe(5); 295 | expect(nodeData?.data?.metadata.truncated).toBe(true); 296 | expect(nodeData?.data?.output?.[0]).toHaveLength(5); 297 | }); 298 | 299 | it('should not truncate when itemsLimit is -1 (unlimited)', () => { 300 | const execution = createMockExecution({ 301 | nodeData: { 302 | 'HTTP Request': createNodeData(50), 303 | }, 304 | }); 305 | 306 | const options: ExecutionFilterOptions = { 307 | mode: 'filtered', 308 | itemsLimit: -1, 309 | }; 310 | 311 | const result = filterExecutionData(execution, options); 312 | const nodeData = result.nodes?.['HTTP Request']; 313 | 314 | expect(nodeData?.data?.metadata.itemsShown).toBe(50); 315 | expect(nodeData?.data?.metadata.totalItems).toBe(50); 316 | expect(nodeData?.data?.metadata.truncated).toBe(false); 317 | }); 318 | 319 | it('should not truncate when items are less than limit', () => { 320 | const execution = createMockExecution({ 321 | nodeData: { 322 | 'HTTP Request': createNodeData(3), 323 | }, 324 | }); 325 | 326 | const options: ExecutionFilterOptions = { 327 | mode: 'filtered', 328 | itemsLimit: 5, 329 | }; 330 | 331 | const result = filterExecutionData(execution, options); 332 | const nodeData = result.nodes?.['HTTP Request']; 333 | 334 | expect(nodeData?.data?.metadata.itemsShown).toBe(3); 335 | expect(nodeData?.data?.metadata.truncated).toBe(false); 336 | }); 337 | 338 | it('should include input data when requested', () => { 339 | const execution = createMockExecution({ 340 | nodeData: { 341 | 'HTTP Request': [ 342 | { 343 | startTime: Date.now(), 344 | executionTime: 100, 345 | inputData: [[{ json: { input: 'test' } }]], 346 | data: { 347 | main: [[{ json: { output: 'result' } }]], 348 | }, 349 | }, 350 | ], 351 | }, 352 | }); 353 | 354 | const options: ExecutionFilterOptions = { 355 | mode: 'filtered', 356 | includeInputData: true, 357 | }; 358 | 359 | const result = filterExecutionData(execution, options); 360 | const nodeData = result.nodes?.['HTTP Request']; 361 | 362 | expect(nodeData?.data?.input).toBeDefined(); 363 | expect(nodeData?.data?.input?.[0]?.[0]?.json?.input).toBe('test'); 364 | }); 365 | 366 | it('should not include input data by default', () => { 367 | const execution = createMockExecution({ 368 | nodeData: { 369 | 'HTTP Request': [ 370 | { 371 | startTime: Date.now(), 372 | executionTime: 100, 373 | inputData: [[{ json: { input: 'test' } }]], 374 | data: { 375 | main: [[{ json: { output: 'result' } }]], 376 | }, 377 | }, 378 | ], 379 | }, 380 | }); 381 | 382 | const options: ExecutionFilterOptions = { 383 | mode: 'filtered', 384 | }; 385 | 386 | const result = filterExecutionData(execution, options); 387 | const nodeData = result.nodes?.['HTTP Request']; 388 | 389 | expect(nodeData?.data?.input).toBeUndefined(); 390 | }); 391 | }); 392 | 393 | /** 394 | * Mode Tests 395 | */ 396 | describe('ExecutionProcessor - Modes', () => { 397 | it('should handle preview mode', () => { 398 | const execution = createMockExecution({ 399 | nodeData: { 400 | 'HTTP Request': createNodeData(50), 401 | }, 402 | }); 403 | 404 | const result = filterExecutionData(execution, { mode: 'preview' }); 405 | 406 | expect(result.mode).toBe('preview'); 407 | expect(result.preview).toBeDefined(); 408 | expect(result.recommendation).toBeDefined(); 409 | expect(result.nodes).toBeUndefined(); 410 | }); 411 | 412 | it('should handle summary mode', () => { 413 | const execution = createMockExecution({ 414 | nodeData: { 415 | 'HTTP Request': createNodeData(50), 416 | }, 417 | }); 418 | 419 | const result = filterExecutionData(execution, { mode: 'summary' }); 420 | 421 | expect(result.mode).toBe('summary'); 422 | expect(result.summary).toBeDefined(); 423 | expect(result.nodes).toBeDefined(); 424 | expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(2); 425 | }); 426 | 427 | it('should handle filtered mode', () => { 428 | const execution = createMockExecution({ 429 | nodeData: { 430 | 'HTTP Request': createNodeData(50), 431 | }, 432 | }); 433 | 434 | const result = filterExecutionData(execution, { 435 | mode: 'filtered', 436 | itemsLimit: 5, 437 | }); 438 | 439 | expect(result.mode).toBe('filtered'); 440 | expect(result.summary).toBeDefined(); 441 | expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(5); 442 | }); 443 | 444 | it('should handle full mode', () => { 445 | const execution = createMockExecution({ 446 | nodeData: { 447 | 'HTTP Request': createNodeData(50), 448 | }, 449 | }); 450 | 451 | const result = filterExecutionData(execution, { mode: 'full' }); 452 | 453 | expect(result.mode).toBe('full'); 454 | expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(50); 455 | expect(result.nodes?.['HTTP Request']?.data?.metadata.truncated).toBe(false); 456 | }); 457 | }); 458 | 459 | /** 460 | * Edge Cases 461 | */ 462 | describe('ExecutionProcessor - Edge Cases', () => { 463 | it('should handle execution with no data', () => { 464 | const execution: Execution = { 465 | id: 'test-1', 466 | workflowId: 'workflow-1', 467 | status: ExecutionStatus.SUCCESS, 468 | mode: 'manual', 469 | finished: true, 470 | startedAt: '2024-01-01T10:00:00.000Z', 471 | stoppedAt: '2024-01-01T10:00:05.000Z', 472 | }; 473 | 474 | const result = filterExecutionData(execution, { mode: 'summary' }); 475 | 476 | expect(result.summary?.totalNodes).toBe(0); 477 | expect(result.summary?.executedNodes).toBe(0); 478 | }); 479 | 480 | it('should handle execution with error', () => { 481 | const execution = createMockExecution({ 482 | nodeData: { 483 | 'HTTP Request': createNodeData(5), 484 | }, 485 | hasError: true, 486 | }); 487 | 488 | const result = filterExecutionData(execution, { mode: 'summary' }); 489 | 490 | expect(result.error).toBeDefined(); 491 | }); 492 | 493 | it('should handle empty node data arrays', () => { 494 | const execution = createMockExecution({ 495 | nodeData: { 496 | 'HTTP Request': [], 497 | }, 498 | }); 499 | 500 | const result = filterExecutionData(execution, { mode: 'summary' }); 501 | 502 | expect(result.nodes?.['HTTP Request']).toBeDefined(); 503 | expect(result.nodes?.['HTTP Request'].itemsOutput).toBe(0); 504 | }); 505 | 506 | it('should handle nested data structures', () => { 507 | const execution = createMockExecution({ 508 | nodeData: { 509 | 'HTTP Request': [ 510 | { 511 | startTime: Date.now(), 512 | executionTime: 100, 513 | data: { 514 | main: [[{ 515 | json: { 516 | deeply: { 517 | nested: { 518 | structure: { 519 | value: 'test', 520 | array: [1, 2, 3], 521 | }, 522 | }, 523 | }, 524 | }, 525 | }]], 526 | }, 527 | }, 528 | ], 529 | }, 530 | }); 531 | 532 | const { preview } = generatePreview(execution); 533 | const structure = preview.nodes['HTTP Request'].dataStructure; 534 | 535 | expect(structure.json.deeply).toBeDefined(); 536 | expect(typeof structure.json.deeply).toBe('object'); 537 | }); 538 | 539 | it('should calculate duration correctly', () => { 540 | const execution = createMockExecution({ 541 | nodeData: { 542 | 'HTTP Request': createNodeData(5), 543 | }, 544 | }); 545 | 546 | const result = filterExecutionData(execution, { mode: 'summary' }); 547 | 548 | expect(result.duration).toBe(5000); // 5 seconds 549 | }); 550 | 551 | it('should handle execution without stop time', () => { 552 | const execution: Execution = { 553 | id: 'test-1', 554 | workflowId: 'workflow-1', 555 | status: ExecutionStatus.WAITING, 556 | mode: 'manual', 557 | finished: false, 558 | startedAt: '2024-01-01T10:00:00.000Z', 559 | data: { 560 | resultData: { 561 | runData: {}, 562 | }, 563 | }, 564 | }; 565 | 566 | const result = filterExecutionData(execution, { mode: 'summary' }); 567 | 568 | expect(result.duration).toBeUndefined(); 569 | expect(result.finished).toBe(false); 570 | }); 571 | }); 572 | 573 | /** 574 | * processExecution Tests 575 | */ 576 | describe('ExecutionProcessor - processExecution', () => { 577 | it('should return original execution when no options provided', () => { 578 | const execution = createMockExecution({ 579 | nodeData: { 580 | 'HTTP Request': createNodeData(5), 581 | }, 582 | }); 583 | 584 | const result = processExecution(execution, {}); 585 | 586 | expect(result).toBe(execution); 587 | }); 588 | 589 | it('should process when mode is specified', () => { 590 | const execution = createMockExecution({ 591 | nodeData: { 592 | 'HTTP Request': createNodeData(5), 593 | }, 594 | }); 595 | 596 | const result = processExecution(execution, { mode: 'preview' }); 597 | 598 | expect(result).not.toBe(execution); 599 | expect((result as any).mode).toBe('preview'); 600 | }); 601 | 602 | it('should process when filtering options are provided', () => { 603 | const execution = createMockExecution({ 604 | nodeData: { 605 | 'HTTP Request': createNodeData(5), 606 | 'Filter': createNodeData(3), 607 | }, 608 | }); 609 | 610 | const result = processExecution(execution, { nodeNames: ['HTTP Request'] }); 611 | 612 | expect(result).not.toBe(execution); 613 | expect((result as any).nodes).toHaveProperty('HTTP Request'); 614 | expect((result as any).nodes).not.toHaveProperty('Filter'); 615 | }); 616 | }); 617 | 618 | /** 619 | * Summary Statistics Tests 620 | */ 621 | describe('ExecutionProcessor - Summary Statistics', () => { 622 | it('should calculate hasMoreData correctly', () => { 623 | const execution = createMockExecution({ 624 | nodeData: { 625 | 'HTTP Request': createNodeData(50), 626 | }, 627 | }); 628 | 629 | const result = filterExecutionData(execution, { 630 | mode: 'summary', 631 | itemsLimit: 2, 632 | }); 633 | 634 | expect(result.summary?.hasMoreData).toBe(true); 635 | }); 636 | 637 | it('should set hasMoreData to false when all data is included', () => { 638 | const execution = createMockExecution({ 639 | nodeData: { 640 | 'HTTP Request': createNodeData(2), 641 | }, 642 | }); 643 | 644 | const result = filterExecutionData(execution, { 645 | mode: 'summary', 646 | itemsLimit: 5, 647 | }); 648 | 649 | expect(result.summary?.hasMoreData).toBe(false); 650 | }); 651 | 652 | it('should count total items correctly across multiple nodes', () => { 653 | const execution = createMockExecution({ 654 | nodeData: { 655 | 'HTTP Request': createNodeData(10), 656 | 'Filter': createNodeData(5), 657 | 'Set': createNodeData(3), 658 | }, 659 | }); 660 | 661 | const result = filterExecutionData(execution, { mode: 'summary' }); 662 | 663 | expect(result.summary?.totalItems).toBe(18); 664 | }); 665 | }); 666 | ``` -------------------------------------------------------------------------------- /tests/integration/database/template-node-configs.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import Database from 'better-sqlite3'; 3 | import { DatabaseAdapter, createDatabaseAdapter } from '../../../src/database/database-adapter'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | /** 8 | * Integration tests for template_node_configs table 9 | * Testing database schema, migrations, and data operations 10 | */ 11 | 12 | describe('Template Node Configs Database Integration', () => { 13 | let db: DatabaseAdapter; 14 | let dbPath: string; 15 | 16 | beforeEach(async () => { 17 | // Create temporary database 18 | dbPath = ':memory:'; 19 | db = await createDatabaseAdapter(dbPath); 20 | 21 | // Apply schema 22 | const schemaPath = path.join(__dirname, '../../../src/database/schema.sql'); 23 | const schema = fs.readFileSync(schemaPath, 'utf-8'); 24 | db.exec(schema); 25 | 26 | // Apply migration 27 | const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql'); 28 | const migration = fs.readFileSync(migrationPath, 'utf-8'); 29 | db.exec(migration); 30 | 31 | // Insert test templates with id 1-1000 to satisfy foreign key constraints 32 | // Tests insert configs with various template_id values, so we pre-create many templates 33 | const stmt = db.prepare(` 34 | INSERT INTO templates ( 35 | id, workflow_id, name, description, views, 36 | nodes_used, created_at, updated_at 37 | ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) 38 | `); 39 | for (let i = 1; i <= 1000; i++) { 40 | stmt.run(i, i, `Test Template ${i}`, 'Test template for node configs', 100, '[]'); 41 | } 42 | }); 43 | 44 | afterEach(() => { 45 | if ('close' in db && typeof db.close === 'function') { 46 | db.close(); 47 | } 48 | }); 49 | 50 | describe('Schema Validation', () => { 51 | it('should create template_node_configs table', () => { 52 | const tableExists = db.prepare(` 53 | SELECT name FROM sqlite_master 54 | WHERE type='table' AND name='template_node_configs' 55 | `).get(); 56 | 57 | expect(tableExists).toBeDefined(); 58 | expect(tableExists).toHaveProperty('name', 'template_node_configs'); 59 | }); 60 | 61 | it('should have all required columns', () => { 62 | const columns = db.prepare(`PRAGMA table_info(template_node_configs)`).all() as any[]; 63 | 64 | const columnNames = columns.map(col => col.name); 65 | expect(columnNames).toContain('id'); 66 | expect(columnNames).toContain('node_type'); 67 | expect(columnNames).toContain('template_id'); 68 | expect(columnNames).toContain('template_name'); 69 | expect(columnNames).toContain('template_views'); 70 | expect(columnNames).toContain('node_name'); 71 | expect(columnNames).toContain('parameters_json'); 72 | expect(columnNames).toContain('credentials_json'); 73 | expect(columnNames).toContain('has_credentials'); 74 | expect(columnNames).toContain('has_expressions'); 75 | expect(columnNames).toContain('complexity'); 76 | expect(columnNames).toContain('use_cases'); 77 | expect(columnNames).toContain('rank'); 78 | expect(columnNames).toContain('created_at'); 79 | }); 80 | 81 | it('should have correct column types and constraints', () => { 82 | const columns = db.prepare(`PRAGMA table_info(template_node_configs)`).all() as any[]; 83 | 84 | const idColumn = columns.find(col => col.name === 'id'); 85 | expect(idColumn.pk).toBe(1); // Primary key 86 | 87 | const nodeTypeColumn = columns.find(col => col.name === 'node_type'); 88 | expect(nodeTypeColumn.notnull).toBe(1); // NOT NULL 89 | 90 | const parametersJsonColumn = columns.find(col => col.name === 'parameters_json'); 91 | expect(parametersJsonColumn.notnull).toBe(1); // NOT NULL 92 | }); 93 | 94 | it('should have complexity CHECK constraint', () => { 95 | // Try to insert invalid complexity 96 | expect(() => { 97 | db.prepare(` 98 | INSERT INTO template_node_configs ( 99 | node_type, template_id, template_name, template_views, 100 | node_name, parameters_json, complexity 101 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 102 | `).run( 103 | 'n8n-nodes-base.test', 104 | 1, 105 | 'Test Template', 106 | 100, 107 | 'Test Node', 108 | '{}', 109 | 'invalid' // Should fail CHECK constraint 110 | ); 111 | }).toThrow(); 112 | }); 113 | 114 | it('should accept valid complexity values', () => { 115 | const validComplexities = ['simple', 'medium', 'complex']; 116 | 117 | validComplexities.forEach((complexity, index) => { 118 | expect(() => { 119 | db.prepare(` 120 | INSERT INTO template_node_configs ( 121 | node_type, template_id, template_name, template_views, 122 | node_name, parameters_json, complexity 123 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 124 | `).run( 125 | 'n8n-nodes-base.test', 126 | index + 1, 127 | 'Test Template', 128 | 100, 129 | 'Test Node', 130 | '{}', 131 | complexity 132 | ); 133 | }).not.toThrow(); 134 | }); 135 | 136 | const count = db.prepare('SELECT COUNT(*) as count FROM template_node_configs').get() as any; 137 | expect(count.count).toBe(3); 138 | }); 139 | }); 140 | 141 | describe('Indexes', () => { 142 | it('should create idx_config_node_type_rank index', () => { 143 | const indexes = db.prepare(` 144 | SELECT name FROM sqlite_master 145 | WHERE type='index' AND tbl_name='template_node_configs' 146 | `).all() as any[]; 147 | 148 | const indexNames = indexes.map(idx => idx.name); 149 | expect(indexNames).toContain('idx_config_node_type_rank'); 150 | }); 151 | 152 | it('should create idx_config_complexity index', () => { 153 | const indexes = db.prepare(` 154 | SELECT name FROM sqlite_master 155 | WHERE type='index' AND tbl_name='template_node_configs' 156 | `).all() as any[]; 157 | 158 | const indexNames = indexes.map(idx => idx.name); 159 | expect(indexNames).toContain('idx_config_complexity'); 160 | }); 161 | 162 | it('should create idx_config_auth index', () => { 163 | const indexes = db.prepare(` 164 | SELECT name FROM sqlite_master 165 | WHERE type='index' AND tbl_name='template_node_configs' 166 | `).all() as any[]; 167 | 168 | const indexNames = indexes.map(idx => idx.name); 169 | expect(indexNames).toContain('idx_config_auth'); 170 | }); 171 | }); 172 | 173 | describe('View: ranked_node_configs', () => { 174 | it('should create ranked_node_configs view', () => { 175 | const viewExists = db.prepare(` 176 | SELECT name FROM sqlite_master 177 | WHERE type='view' AND name='ranked_node_configs' 178 | `).get(); 179 | 180 | expect(viewExists).toBeDefined(); 181 | expect(viewExists).toHaveProperty('name', 'ranked_node_configs'); 182 | }); 183 | 184 | it('should return only top 5 ranked configs per node type', () => { 185 | // Insert 10 configs for same node type with different ranks 186 | for (let i = 1; i <= 10; i++) { 187 | db.prepare(` 188 | INSERT INTO template_node_configs ( 189 | node_type, template_id, template_name, template_views, 190 | node_name, parameters_json, rank 191 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 192 | `).run( 193 | 'n8n-nodes-base.httpRequest', 194 | i, 195 | `Template ${i}`, 196 | 1000 - (i * 50), // Decreasing views 197 | 'HTTP Request', 198 | '{}', 199 | i // Rank 1-10 200 | ); 201 | } 202 | 203 | const rankedConfigs = db.prepare('SELECT * FROM ranked_node_configs').all() as any[]; 204 | 205 | // Should only return rank 1-5 206 | expect(rankedConfigs).toHaveLength(5); 207 | expect(Math.max(...rankedConfigs.map(c => c.rank))).toBe(5); 208 | expect(Math.min(...rankedConfigs.map(c => c.rank))).toBe(1); 209 | }); 210 | 211 | it('should order by node_type and rank', () => { 212 | // Insert configs for multiple node types 213 | const configs = [ 214 | { nodeType: 'n8n-nodes-base.webhook', rank: 2 }, 215 | { nodeType: 'n8n-nodes-base.webhook', rank: 1 }, 216 | { nodeType: 'n8n-nodes-base.httpRequest', rank: 2 }, 217 | { nodeType: 'n8n-nodes-base.httpRequest', rank: 1 }, 218 | ]; 219 | 220 | configs.forEach((config, index) => { 221 | db.prepare(` 222 | INSERT INTO template_node_configs ( 223 | node_type, template_id, template_name, template_views, 224 | node_name, parameters_json, rank 225 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 226 | `).run( 227 | config.nodeType, 228 | index + 1, 229 | `Template ${index}`, 230 | 100, 231 | 'Node', 232 | '{}', 233 | config.rank 234 | ); 235 | }); 236 | 237 | const rankedConfigs = db.prepare('SELECT * FROM ranked_node_configs ORDER BY node_type, rank').all() as any[]; 238 | 239 | // First two should be httpRequest rank 1, 2 240 | expect(rankedConfigs[0].node_type).toBe('n8n-nodes-base.httpRequest'); 241 | expect(rankedConfigs[0].rank).toBe(1); 242 | expect(rankedConfigs[1].node_type).toBe('n8n-nodes-base.httpRequest'); 243 | expect(rankedConfigs[1].rank).toBe(2); 244 | 245 | // Last two should be webhook rank 1, 2 246 | expect(rankedConfigs[2].node_type).toBe('n8n-nodes-base.webhook'); 247 | expect(rankedConfigs[2].rank).toBe(1); 248 | expect(rankedConfigs[3].node_type).toBe('n8n-nodes-base.webhook'); 249 | expect(rankedConfigs[3].rank).toBe(2); 250 | }); 251 | }); 252 | 253 | describe('Foreign Key Constraints', () => { 254 | beforeEach(() => { 255 | // Enable foreign keys 256 | db.exec('PRAGMA foreign_keys = ON'); 257 | // Note: Templates are already created in the main beforeEach 258 | }); 259 | 260 | it('should allow inserting config with valid template_id', () => { 261 | expect(() => { 262 | db.prepare(` 263 | INSERT INTO template_node_configs ( 264 | node_type, template_id, template_name, template_views, 265 | node_name, parameters_json 266 | ) VALUES (?, ?, ?, ?, ?, ?) 267 | `).run( 268 | 'n8n-nodes-base.test', 269 | 1, // Valid template_id 270 | 'Test Template', 271 | 100, 272 | 'Test Node', 273 | '{}' 274 | ); 275 | }).not.toThrow(); 276 | }); 277 | 278 | it('should cascade delete configs when template is deleted', () => { 279 | // Insert config 280 | db.prepare(` 281 | INSERT INTO template_node_configs ( 282 | node_type, template_id, template_name, template_views, 283 | node_name, parameters_json 284 | ) VALUES (?, ?, ?, ?, ?, ?) 285 | `).run( 286 | 'n8n-nodes-base.test', 287 | 1, 288 | 'Test Template', 289 | 100, 290 | 'Test Node', 291 | '{}' 292 | ); 293 | 294 | // Verify config exists 295 | let configs = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').all(1) as any[]; 296 | expect(configs).toHaveLength(1); 297 | 298 | // Delete template 299 | db.prepare('DELETE FROM templates WHERE id = ?').run(1); 300 | 301 | // Verify config is deleted (CASCADE) 302 | configs = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').all(1) as any[]; 303 | expect(configs).toHaveLength(0); 304 | }); 305 | }); 306 | 307 | describe('Data Operations', () => { 308 | it('should insert and retrieve config with all fields', () => { 309 | const testConfig = { 310 | node_type: 'n8n-nodes-base.webhook', 311 | template_id: 1, 312 | template_name: 'Webhook Template', 313 | template_views: 2000, 314 | node_name: 'Webhook Trigger', 315 | parameters_json: JSON.stringify({ 316 | httpMethod: 'POST', 317 | path: 'webhook-test', 318 | responseMode: 'lastNode' 319 | }), 320 | credentials_json: JSON.stringify({ 321 | webhookAuth: { id: '1', name: 'Webhook Auth' } 322 | }), 323 | has_credentials: 1, 324 | has_expressions: 1, 325 | complexity: 'medium', 326 | use_cases: JSON.stringify(['webhook processing', 'automation triggers']), 327 | rank: 1 328 | }; 329 | 330 | db.prepare(` 331 | INSERT INTO template_node_configs ( 332 | node_type, template_id, template_name, template_views, 333 | node_name, parameters_json, credentials_json, 334 | has_credentials, has_expressions, complexity, use_cases, rank 335 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 336 | `).run(...Object.values(testConfig)); 337 | 338 | const retrieved = db.prepare('SELECT * FROM template_node_configs WHERE id = 1').get() as any; 339 | 340 | expect(retrieved.node_type).toBe(testConfig.node_type); 341 | expect(retrieved.template_id).toBe(testConfig.template_id); 342 | expect(retrieved.template_name).toBe(testConfig.template_name); 343 | expect(retrieved.template_views).toBe(testConfig.template_views); 344 | expect(retrieved.node_name).toBe(testConfig.node_name); 345 | expect(retrieved.parameters_json).toBe(testConfig.parameters_json); 346 | expect(retrieved.credentials_json).toBe(testConfig.credentials_json); 347 | expect(retrieved.has_credentials).toBe(testConfig.has_credentials); 348 | expect(retrieved.has_expressions).toBe(testConfig.has_expressions); 349 | expect(retrieved.complexity).toBe(testConfig.complexity); 350 | expect(retrieved.use_cases).toBe(testConfig.use_cases); 351 | expect(retrieved.rank).toBe(testConfig.rank); 352 | expect(retrieved.created_at).toBeDefined(); 353 | }); 354 | 355 | it('should handle nullable fields correctly', () => { 356 | db.prepare(` 357 | INSERT INTO template_node_configs ( 358 | node_type, template_id, template_name, template_views, 359 | node_name, parameters_json 360 | ) VALUES (?, ?, ?, ?, ?, ?) 361 | `).run( 362 | 'n8n-nodes-base.test', 363 | 1, 364 | 'Test', 365 | 100, 366 | 'Node', 367 | '{}' 368 | ); 369 | 370 | const retrieved = db.prepare('SELECT * FROM template_node_configs WHERE id = 1').get() as any; 371 | 372 | expect(retrieved.credentials_json).toBeNull(); 373 | expect(retrieved.has_credentials).toBe(0); // Default value 374 | expect(retrieved.has_expressions).toBe(0); // Default value 375 | expect(retrieved.rank).toBe(0); // Default value 376 | }); 377 | 378 | it('should update rank values', () => { 379 | // Insert multiple configs 380 | for (let i = 1; i <= 3; i++) { 381 | db.prepare(` 382 | INSERT INTO template_node_configs ( 383 | node_type, template_id, template_name, template_views, 384 | node_name, parameters_json, rank 385 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 386 | `).run( 387 | 'n8n-nodes-base.test', 388 | i, 389 | 'Template', 390 | 100, 391 | 'Node', 392 | '{}', 393 | 0 // Initial rank 394 | ); 395 | } 396 | 397 | // Update ranks 398 | db.exec(` 399 | UPDATE template_node_configs 400 | SET rank = ( 401 | SELECT COUNT(*) + 1 402 | FROM template_node_configs AS t2 403 | WHERE t2.node_type = template_node_configs.node_type 404 | AND t2.template_views > template_node_configs.template_views 405 | ) 406 | `); 407 | 408 | const configs = db.prepare('SELECT * FROM template_node_configs ORDER BY rank').all() as any[]; 409 | 410 | // All should have same rank (same views) 411 | expect(configs.every(c => c.rank === 1)).toBe(true); 412 | }); 413 | 414 | it('should delete configs with rank > 10', () => { 415 | // Insert 15 configs with different ranks 416 | for (let i = 1; i <= 15; i++) { 417 | db.prepare(` 418 | INSERT INTO template_node_configs ( 419 | node_type, template_id, template_name, template_views, 420 | node_name, parameters_json, rank 421 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 422 | `).run( 423 | 'n8n-nodes-base.test', 424 | i, 425 | 'Template', 426 | 100, 427 | 'Node', 428 | '{}', 429 | i // Rank 1-15 430 | ); 431 | } 432 | 433 | // Delete configs with rank > 10 434 | db.exec(` 435 | DELETE FROM template_node_configs 436 | WHERE id NOT IN ( 437 | SELECT id FROM template_node_configs 438 | WHERE rank <= 10 439 | ORDER BY node_type, rank 440 | ) 441 | `); 442 | 443 | const remaining = db.prepare('SELECT * FROM template_node_configs').all() as any[]; 444 | 445 | expect(remaining).toHaveLength(10); 446 | expect(Math.max(...remaining.map(c => c.rank))).toBe(10); 447 | }); 448 | }); 449 | 450 | describe('Query Performance', () => { 451 | beforeEach(() => { 452 | // Insert 1000 configs for performance testing 453 | const stmt = db.prepare(` 454 | INSERT INTO template_node_configs ( 455 | node_type, template_id, template_name, template_views, 456 | node_name, parameters_json, rank 457 | ) VALUES (?, ?, ?, ?, ?, ?, ?) 458 | `); 459 | 460 | const nodeTypes = [ 461 | 'n8n-nodes-base.httpRequest', 462 | 'n8n-nodes-base.webhook', 463 | 'n8n-nodes-base.slack', 464 | 'n8n-nodes-base.googleSheets', 465 | 'n8n-nodes-base.code' 466 | ]; 467 | 468 | for (let i = 1; i <= 1000; i++) { 469 | const nodeType = nodeTypes[i % nodeTypes.length]; 470 | stmt.run( 471 | nodeType, 472 | i, 473 | `Template ${i}`, 474 | Math.floor(Math.random() * 10000), 475 | 'Node', 476 | '{}', 477 | (i % 10) + 1 // Rank 1-10 478 | ); 479 | } 480 | }); 481 | 482 | it('should query by node_type and rank efficiently', () => { 483 | const start = Date.now(); 484 | const results = db.prepare(` 485 | SELECT * FROM template_node_configs 486 | WHERE node_type = ? 487 | ORDER BY rank 488 | LIMIT 3 489 | `).all('n8n-nodes-base.httpRequest') as any[]; 490 | const duration = Date.now() - start; 491 | 492 | expect(results.length).toBeGreaterThan(0); 493 | expect(duration).toBeLessThan(10); // Should be very fast with index 494 | }); 495 | 496 | it('should filter by complexity efficiently', () => { 497 | // First set some complexity values 498 | db.exec(`UPDATE template_node_configs SET complexity = 'simple' WHERE id % 3 = 0`); 499 | db.exec(`UPDATE template_node_configs SET complexity = 'medium' WHERE id % 3 = 1`); 500 | db.exec(`UPDATE template_node_configs SET complexity = 'complex' WHERE id % 3 = 2`); 501 | 502 | const start = Date.now(); 503 | const results = db.prepare(` 504 | SELECT * FROM template_node_configs 505 | WHERE node_type = ? AND complexity = ? 506 | ORDER BY rank 507 | LIMIT 5 508 | `).all('n8n-nodes-base.webhook', 'simple') as any[]; 509 | const duration = Date.now() - start; 510 | 511 | expect(duration).toBeLessThan(10); // Should be fast with index 512 | }); 513 | }); 514 | 515 | describe('Migration Idempotency', () => { 516 | it('should be safe to run migration multiple times', () => { 517 | const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql'); 518 | const migration = fs.readFileSync(migrationPath, 'utf-8'); 519 | 520 | // Run migration again 521 | expect(() => { 522 | db.exec(migration); 523 | }).not.toThrow(); 524 | 525 | // Table should still exist 526 | const tableExists = db.prepare(` 527 | SELECT name FROM sqlite_master 528 | WHERE type='table' AND name='template_node_configs' 529 | `).get(); 530 | 531 | expect(tableExists).toBeDefined(); 532 | }); 533 | }); 534 | }); 535 | ``` -------------------------------------------------------------------------------- /tests/unit/services/config-validator-node-specific.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { ConfigValidator } from '@/services/config-validator'; 3 | import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator'; 4 | 5 | // Mock the database 6 | vi.mock('better-sqlite3'); 7 | 8 | describe('ConfigValidator - Node-Specific Validation', () => { 9 | beforeEach(() => { 10 | vi.clearAllMocks(); 11 | }); 12 | 13 | describe('HTTP Request node validation', () => { 14 | it('should perform HTTP Request specific validation', () => { 15 | const nodeType = 'nodes-base.httpRequest'; 16 | const config = { 17 | method: 'POST', 18 | url: 'invalid-url', // Missing protocol 19 | sendBody: false 20 | }; 21 | const properties = [ 22 | { name: 'method', type: 'options' }, 23 | { name: 'url', type: 'string' }, 24 | { name: 'sendBody', type: 'boolean' } 25 | ]; 26 | 27 | const result = ConfigValidator.validate(nodeType, config, properties); 28 | 29 | expect(result.valid).toBe(false); 30 | expect(result.errors).toHaveLength(1); 31 | expect(result.errors[0]).toMatchObject({ 32 | type: 'invalid_value', 33 | property: 'url', 34 | message: 'URL must start with http:// or https://' 35 | }); 36 | expect(result.warnings).toHaveLength(1); 37 | expect(result.warnings[0]).toMatchObject({ 38 | type: 'missing_common', 39 | property: 'sendBody', 40 | message: 'POST requests typically send a body' 41 | }); 42 | expect(result.autofix).toMatchObject({ 43 | sendBody: true, 44 | contentType: 'json' 45 | }); 46 | }); 47 | 48 | it('should validate HTTP Request with authentication in API URLs', () => { 49 | const nodeType = 'nodes-base.httpRequest'; 50 | const config = { 51 | method: 'GET', 52 | url: 'https://api.github.com/user/repos', 53 | authentication: 'none' 54 | }; 55 | const properties = [ 56 | { name: 'method', type: 'options' }, 57 | { name: 'url', type: 'string' }, 58 | { name: 'authentication', type: 'options' } 59 | ]; 60 | 61 | const result = ConfigValidator.validate(nodeType, config, properties); 62 | 63 | expect(result.warnings.some(w => 64 | w.type === 'security' && 65 | w.message.includes('API endpoints typically require authentication') 66 | )).toBe(true); 67 | }); 68 | 69 | it('should validate JSON in HTTP Request body', () => { 70 | const nodeType = 'nodes-base.httpRequest'; 71 | const config = { 72 | method: 'POST', 73 | url: 'https://api.example.com', 74 | contentType: 'json', 75 | body: '{"invalid": json}' // Invalid JSON 76 | }; 77 | const properties = [ 78 | { name: 'method', type: 'options' }, 79 | { name: 'url', type: 'string' }, 80 | { name: 'contentType', type: 'options' }, 81 | { name: 'body', type: 'string' } 82 | ]; 83 | 84 | const result = ConfigValidator.validate(nodeType, config, properties); 85 | 86 | expect(result.errors.some(e => 87 | e.property === 'body' && 88 | e.message.includes('Invalid JSON') 89 | )); 90 | }); 91 | 92 | it('should handle webhook-specific validation', () => { 93 | const nodeType = 'nodes-base.webhook'; 94 | const config = { 95 | httpMethod: 'GET', 96 | path: 'webhook-endpoint' // Missing leading slash 97 | }; 98 | const properties = [ 99 | { name: 'httpMethod', type: 'options' }, 100 | { name: 'path', type: 'string' } 101 | ]; 102 | 103 | const result = ConfigValidator.validate(nodeType, config, properties); 104 | 105 | expect(result.warnings.some(w => 106 | w.property === 'path' && 107 | w.message.includes('should start with /') 108 | )); 109 | }); 110 | }); 111 | 112 | describe('Code node validation', () => { 113 | it('should validate Code node configurations', () => { 114 | const nodeType = 'nodes-base.code'; 115 | const config = { 116 | language: 'javascript', 117 | jsCode: '' // Empty code 118 | }; 119 | const properties = [ 120 | { name: 'language', type: 'options' }, 121 | { name: 'jsCode', type: 'string' } 122 | ]; 123 | 124 | const result = ConfigValidator.validate(nodeType, config, properties); 125 | 126 | expect(result.valid).toBe(false); 127 | expect(result.errors).toHaveLength(1); 128 | expect(result.errors[0]).toMatchObject({ 129 | type: 'missing_required', 130 | property: 'jsCode', 131 | message: 'Code cannot be empty' 132 | }); 133 | }); 134 | 135 | it('should validate JavaScript syntax in Code node', () => { 136 | const nodeType = 'nodes-base.code'; 137 | const config = { 138 | language: 'javascript', 139 | jsCode: ` 140 | const data = { foo: "bar" }; 141 | if (data.foo { // Missing closing parenthesis 142 | return [{json: data}]; 143 | } 144 | ` 145 | }; 146 | const properties = [ 147 | { name: 'language', type: 'options' }, 148 | { name: 'jsCode', type: 'string' } 149 | ]; 150 | 151 | const result = ConfigValidator.validate(nodeType, config, properties); 152 | 153 | expect(result.errors.some(e => e.message.includes('Unbalanced'))); 154 | expect(result.warnings).toHaveLength(1); 155 | }); 156 | 157 | it('should validate n8n-specific patterns in Code node', () => { 158 | const nodeType = 'nodes-base.code'; 159 | const config = { 160 | language: 'javascript', 161 | jsCode: ` 162 | // Process data without returning 163 | const processedData = items.map(item => ({ 164 | ...item.json, 165 | processed: true 166 | })); 167 | // No output provided 168 | ` 169 | }; 170 | const properties = [ 171 | { name: 'language', type: 'options' }, 172 | { name: 'jsCode', type: 'string' } 173 | ]; 174 | 175 | const result = ConfigValidator.validate(nodeType, config, properties); 176 | 177 | // The warning should be about missing return statement 178 | expect(result.warnings.some(w => w.type === 'missing_common' && w.message.includes('No return statement found'))).toBe(true); 179 | }); 180 | 181 | it('should handle empty code in Code node', () => { 182 | const nodeType = 'nodes-base.code'; 183 | const config = { 184 | language: 'javascript', 185 | jsCode: ' \n \t \n ' // Just whitespace 186 | }; 187 | const properties = [ 188 | { name: 'language', type: 'options' }, 189 | { name: 'jsCode', type: 'string' } 190 | ]; 191 | 192 | const result = ConfigValidator.validate(nodeType, config, properties); 193 | 194 | expect(result.valid).toBe(false); 195 | expect(result.errors.some(e => 196 | e.type === 'missing_required' && 197 | e.message.includes('Code cannot be empty') 198 | )).toBe(true); 199 | }); 200 | 201 | it('should validate complex return patterns in Code node', () => { 202 | const nodeType = 'nodes-base.code'; 203 | const config = { 204 | language: 'javascript', 205 | jsCode: ` 206 | return ["string1", "string2", "string3"]; 207 | ` 208 | }; 209 | const properties = [ 210 | { name: 'language', type: 'options' }, 211 | { name: 'jsCode', type: 'string' } 212 | ]; 213 | 214 | const result = ConfigValidator.validate(nodeType, config, properties); 215 | 216 | expect(result.warnings.some(w => 217 | w.type === 'invalid_value' && 218 | w.message.includes('Items must be objects with json property') 219 | )).toBe(true); 220 | }); 221 | 222 | it('should validate Code node with $helpers usage', () => { 223 | const nodeType = 'nodes-base.code'; 224 | const config = { 225 | language: 'javascript', 226 | jsCode: ` 227 | const workflow = $helpers.getWorkflowStaticData(); 228 | workflow.counter = (workflow.counter || 0) + 1; 229 | return [{json: {count: workflow.counter}}]; 230 | ` 231 | }; 232 | const properties = [ 233 | { name: 'language', type: 'options' }, 234 | { name: 'jsCode', type: 'string' } 235 | ]; 236 | 237 | const result = ConfigValidator.validate(nodeType, config, properties); 238 | 239 | expect(result.warnings.some(w => 240 | w.type === 'best_practice' && 241 | w.message.includes('$helpers is only available in Code nodes') 242 | )).toBe(true); 243 | }); 244 | 245 | it('should detect incorrect $helpers.getWorkflowStaticData usage', () => { 246 | const nodeType = 'nodes-base.code'; 247 | const config = { 248 | language: 'javascript', 249 | jsCode: ` 250 | const data = $helpers.getWorkflowStaticData; // Missing parentheses 251 | return [{json: {data}}]; 252 | ` 253 | }; 254 | const properties = [ 255 | { name: 'language', type: 'options' }, 256 | { name: 'jsCode', type: 'string' } 257 | ]; 258 | 259 | const result = ConfigValidator.validate(nodeType, config, properties); 260 | 261 | expect(result.errors.some(e => 262 | e.type === 'invalid_value' && 263 | e.message.includes('getWorkflowStaticData requires parentheses') 264 | )).toBe(true); 265 | }); 266 | 267 | it('should validate console.log usage', () => { 268 | const nodeType = 'nodes-base.code'; 269 | const config = { 270 | language: 'javascript', 271 | jsCode: ` 272 | console.log('Debug info:', items); 273 | return items; 274 | ` 275 | }; 276 | const properties = [ 277 | { name: 'language', type: 'options' }, 278 | { name: 'jsCode', type: 'string' } 279 | ]; 280 | 281 | const result = ConfigValidator.validate(nodeType, config, properties); 282 | 283 | expect(result.warnings.some(w => 284 | w.type === 'best_practice' && 285 | w.message.includes('console.log output appears in n8n execution logs') 286 | )).toBe(true); 287 | }); 288 | 289 | it('should validate $json usage warning', () => { 290 | const nodeType = 'nodes-base.code'; 291 | const config = { 292 | language: 'javascript', 293 | jsCode: ` 294 | const data = $json.myField; 295 | return [{json: {processed: data}}]; 296 | ` 297 | }; 298 | const properties = [ 299 | { name: 'language', type: 'options' }, 300 | { name: 'jsCode', type: 'string' } 301 | ]; 302 | 303 | const result = ConfigValidator.validate(nodeType, config, properties); 304 | 305 | expect(result.warnings.some(w => 306 | w.type === 'best_practice' && 307 | w.message.includes('$json only works in "Run Once for Each Item" mode') 308 | )).toBe(true); 309 | }); 310 | 311 | it('should not warn about properties for Code nodes', () => { 312 | const nodeType = 'nodes-base.code'; 313 | const config = { 314 | language: 'javascript', 315 | jsCode: 'return items;', 316 | unusedProperty: 'this should not generate a warning for Code nodes' 317 | }; 318 | const properties = [ 319 | { name: 'language', type: 'options' }, 320 | { name: 'jsCode', type: 'string' } 321 | ]; 322 | 323 | const result = ConfigValidator.validate(nodeType, config, properties); 324 | 325 | // Code nodes should skip the common issues check that warns about unused properties 326 | expect(result.warnings.some(w => 327 | w.type === 'inefficient' && 328 | w.property === 'unusedProperty' 329 | )).toBe(false); 330 | }); 331 | 332 | it('should validate crypto module usage', () => { 333 | const nodeType = 'nodes-base.code'; 334 | const config = { 335 | language: 'javascript', 336 | jsCode: ` 337 | const uuid = crypto.randomUUID(); 338 | return [{json: {id: uuid}}]; 339 | ` 340 | }; 341 | const properties = [ 342 | { name: 'language', type: 'options' }, 343 | { name: 'jsCode', type: 'string' } 344 | ]; 345 | 346 | const result = ConfigValidator.validate(nodeType, config, properties); 347 | 348 | expect(result.warnings.some(w => 349 | w.type === 'invalid_value' && 350 | w.message.includes('Using crypto without require') 351 | )).toBe(true); 352 | }); 353 | 354 | it('should suggest error handling for complex code', () => { 355 | const nodeType = 'nodes-base.code'; 356 | const config = { 357 | language: 'javascript', 358 | jsCode: ` 359 | const apiUrl = items[0].json.url; 360 | const response = await fetch(apiUrl); 361 | const data = await response.json(); 362 | return [{json: data}]; 363 | ` 364 | }; 365 | const properties = [ 366 | { name: 'language', type: 'options' }, 367 | { name: 'jsCode', type: 'string' } 368 | ]; 369 | 370 | const result = ConfigValidator.validate(nodeType, config, properties); 371 | 372 | expect(result.suggestions.some(s => 373 | s.includes('Consider adding error handling') 374 | )); 375 | }); 376 | 377 | it('should suggest error handling for non-trivial code', () => { 378 | const nodeType = 'nodes-base.code'; 379 | const config = { 380 | language: 'javascript', 381 | jsCode: Array(10).fill('const x = 1;').join('\n') + '\nreturn items;' 382 | }; 383 | const properties = [ 384 | { name: 'language', type: 'options' }, 385 | { name: 'jsCode', type: 'string' } 386 | ]; 387 | 388 | const result = ConfigValidator.validate(nodeType, config, properties); 389 | 390 | expect(result.suggestions.some(s => s.includes('error handling'))); 391 | }); 392 | 393 | it('should validate async operations without await', () => { 394 | const nodeType = 'nodes-base.code'; 395 | const config = { 396 | language: 'javascript', 397 | jsCode: ` 398 | const promise = fetch('https://api.example.com'); 399 | return [{json: {data: promise}}]; 400 | ` 401 | }; 402 | const properties = [ 403 | { name: 'language', type: 'options' }, 404 | { name: 'jsCode', type: 'string' } 405 | ]; 406 | 407 | const result = ConfigValidator.validate(nodeType, config, properties); 408 | 409 | expect(result.warnings.some(w => 410 | w.type === 'best_practice' && 411 | w.message.includes('Async operation without await') 412 | )).toBe(true); 413 | }); 414 | }); 415 | 416 | describe('Python Code node validation', () => { 417 | it('should validate Python code syntax', () => { 418 | const nodeType = 'nodes-base.code'; 419 | const config = { 420 | language: 'python', 421 | pythonCode: ` 422 | def process_data(): 423 | return [{"json": {"test": True}] # Missing closing bracket 424 | ` 425 | }; 426 | const properties = [ 427 | { name: 'language', type: 'options' }, 428 | { name: 'pythonCode', type: 'string' } 429 | ]; 430 | 431 | const result = ConfigValidator.validate(nodeType, config, properties); 432 | 433 | expect(result.errors.some(e => 434 | e.type === 'syntax_error' && 435 | e.message.includes('Unmatched bracket') 436 | )).toBe(true); 437 | }); 438 | 439 | it('should detect mixed indentation in Python code', () => { 440 | const nodeType = 'nodes-base.code'; 441 | const config = { 442 | language: 'python', 443 | pythonCode: ` 444 | def process(): 445 | x = 1 446 | y = 2 # This line uses tabs 447 | return [{"json": {"x": x, "y": y}}] 448 | ` 449 | }; 450 | const properties = [ 451 | { name: 'language', type: 'options' }, 452 | { name: 'pythonCode', type: 'string' } 453 | ]; 454 | 455 | const result = ConfigValidator.validate(nodeType, config, properties); 456 | 457 | expect(result.errors.some(e => 458 | e.type === 'syntax_error' && 459 | e.message.includes('Mixed indentation') 460 | )).toBe(true); 461 | }); 462 | 463 | it('should warn about incorrect n8n return patterns', () => { 464 | const nodeType = 'nodes-base.code'; 465 | const config = { 466 | language: 'python', 467 | pythonCode: ` 468 | result = {"data": "value"} 469 | return result # Should return array of objects with json key 470 | ` 471 | }; 472 | const properties = [ 473 | { name: 'language', type: 'options' }, 474 | { name: 'pythonCode', type: 'string' } 475 | ]; 476 | 477 | const result = ConfigValidator.validate(nodeType, config, properties); 478 | 479 | expect(result.warnings.some(w => 480 | w.type === 'invalid_value' && 481 | w.message.includes('Must return array of objects with json key') 482 | )).toBe(true); 483 | }); 484 | 485 | it('should warn about using external libraries in Python code', () => { 486 | const nodeType = 'nodes-base.code'; 487 | const config = { 488 | language: 'python', 489 | pythonCode: ` 490 | import pandas as pd 491 | import requests 492 | 493 | df = pd.DataFrame(items) 494 | response = requests.get('https://api.example.com') 495 | return [{"json": {"data": response.json()}}] 496 | ` 497 | }; 498 | const properties = [ 499 | { name: 'language', type: 'options' }, 500 | { name: 'pythonCode', type: 'string' } 501 | ]; 502 | 503 | const result = ConfigValidator.validate(nodeType, config, properties); 504 | 505 | expect(result.warnings.some(w => 506 | w.type === 'invalid_value' && 507 | w.message.includes('External libraries not available') 508 | )).toBe(true); 509 | }); 510 | 511 | it('should validate Python code with print statements', () => { 512 | const nodeType = 'nodes-base.code'; 513 | const config = { 514 | language: 'python', 515 | pythonCode: ` 516 | print("Debug:", items) 517 | processed = [] 518 | for item in items: 519 | print(f"Processing: {item}") 520 | processed.append({"json": item["json"]}) 521 | return processed 522 | ` 523 | }; 524 | const properties = [ 525 | { name: 'language', type: 'options' }, 526 | { name: 'pythonCode', type: 'string' } 527 | ]; 528 | 529 | const result = ConfigValidator.validate(nodeType, config, properties); 530 | 531 | expect(result.warnings.some(w => 532 | w.type === 'best_practice' && 533 | w.message.includes('print() output appears in n8n execution logs') 534 | )).toBe(true); 535 | }); 536 | }); 537 | 538 | describe('Database node validation', () => { 539 | it('should validate database query security', () => { 540 | const nodeType = 'nodes-base.postgres'; 541 | const config = { 542 | query: 'DELETE FROM users;' // Missing WHERE clause 543 | }; 544 | const properties = [ 545 | { name: 'query', type: 'string' } 546 | ]; 547 | 548 | const result = ConfigValidator.validate(nodeType, config, properties); 549 | 550 | expect(result.warnings.some(w => 551 | w.type === 'security' && 552 | w.message.includes('DELETE query without WHERE clause') 553 | )).toBe(true); 554 | }); 555 | 556 | it('should check for SQL injection vulnerabilities', () => { 557 | const nodeType = 'nodes-base.mysql'; 558 | const config = { 559 | query: 'SELECT * FROM users WHERE id = ${userId}' 560 | }; 561 | const properties = [ 562 | { name: 'query', type: 'string' } 563 | ]; 564 | 565 | const result = ConfigValidator.validate(nodeType, config, properties); 566 | 567 | expect(result.warnings.some(w => 568 | w.type === 'security' && 569 | w.message.includes('SQL injection') 570 | )).toBe(true); 571 | }); 572 | 573 | it('should validate SQL SELECT * performance warning', () => { 574 | const nodeType = 'nodes-base.postgres'; 575 | const config = { 576 | query: 'SELECT * FROM large_table WHERE status = "active"' 577 | }; 578 | const properties = [ 579 | { name: 'query', type: 'string' } 580 | ]; 581 | 582 | const result = ConfigValidator.validate(nodeType, config, properties); 583 | 584 | expect(result.suggestions.some(s => 585 | s.includes('Consider selecting specific columns') 586 | )).toBe(true); 587 | }); 588 | }); 589 | }); ```