This is page 19 of 46. Use http://codebase.md/czlonkowski/n8n-mcp?lines=false&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-sanitizer.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── sqljs-memory-leak.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-sanitizer.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/mcp/tools-documentation.ts: -------------------------------------------------------------------------------- ```typescript import { toolsDocumentation } from './tool-docs'; export function getToolDocumentation(toolName: string, depth: 'essentials' | 'full' = 'essentials'): string { // Check for special documentation topics if (toolName === 'javascript_code_node_guide') { return getJavaScriptCodeNodeGuide(depth); } if (toolName === 'python_code_node_guide') { return getPythonCodeNodeGuide(depth); } const tool = toolsDocumentation[toolName]; if (!tool) { return `Tool '${toolName}' not found. Use tools_documentation() to see available tools.`; } if (depth === 'essentials') { const { essentials } = tool; return `# ${tool.name} ${essentials.description} **Example**: ${essentials.example} **Key parameters**: ${essentials.keyParameters.join(', ')} **Performance**: ${essentials.performance} **Tips**: ${essentials.tips.map(tip => `- ${tip}`).join('\n')} For full documentation, use: tools_documentation({topic: "${toolName}", depth: "full"})`; } // Full documentation const { full } = tool; return `# ${tool.name} ${full.description} ## Parameters ${Object.entries(full.parameters).map(([param, info]) => `- **${param}** (${info.type}${info.required ? ', required' : ''}): ${info.description}` ).join('\n')} ## Returns ${full.returns} ## Examples ${full.examples.map(ex => `\`\`\`javascript\n${ex}\n\`\`\``).join('\n\n')} ## Common Use Cases ${full.useCases.map(uc => `- ${uc}`).join('\n')} ## Performance ${full.performance} ## Best Practices ${full.bestPractices.map(bp => `- ${bp}`).join('\n')} ## Common Pitfalls ${full.pitfalls.map(p => `- ${p}`).join('\n')} ## Related Tools ${full.relatedTools.map(t => `- ${t}`).join('\n')}`; } export function getToolsOverview(depth: 'essentials' | 'full' = 'essentials'): string { // Get version info from package.json const packageJson = require('../../package.json'); const supportedN8nVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, '') || 'latest'; if (depth === 'essentials') { return `# n8n MCP Tools Reference ## Important: Compatibility Notice ⚠️ This MCP server is tested with n8n version ${supportedN8nVersion}. Inform the user to check their n8n version matches or is compatible with the supported version listed above. ## Code Node Configuration When working with Code nodes, always start by calling the relevant guide: - tools_documentation({topic: "javascript_code_node_guide"}) for JavaScript Code nodes - tools_documentation({topic: "python_code_node_guide"}) for Python Code nodes ## Standard Workflow Pattern 1. **Find** the node you need: - search_nodes({query: "slack"}) - Search by keyword - list_nodes({category: "communication"}) - List by category - list_ai_tools() - List AI-capable nodes 2. **Configure** the node: - get_node_essentials("nodes-base.slack") - Get essential properties only (5KB) - get_node_info("nodes-base.slack") - Get complete schema (100KB+) - search_node_properties("nodes-base.slack", "auth") - Find specific properties 3. **Validate** before deployment: - validate_node_minimal("nodes-base.slack", config) - Check required fields - validate_node_operation("nodes-base.slack", config) - Full validation with fixes - validate_workflow(workflow) - Validate entire workflow ## Tool Categories **Discovery Tools** - search_nodes - Full-text search across all nodes - list_nodes - List nodes with filtering by category, package, or type - list_ai_tools - List all AI-capable nodes with usage guidance **Configuration Tools** - get_node_essentials - Returns 10-20 key properties with examples - get_node_info - Returns complete node schema with all properties - search_node_properties - Search for specific properties within a node - get_property_dependencies - Analyze property visibility dependencies **Validation Tools** - validate_node_minimal - Quick validation of required fields only - validate_node_operation - Full validation with operation awareness - validate_workflow - Complete workflow validation including connections **Template Tools** - list_tasks - List common task templates - get_node_for_task - Get pre-configured node for specific tasks - search_templates - Search workflow templates by keyword - get_template - Get complete workflow JSON by ID **n8n API Tools** (requires N8N_API_URL configuration) - n8n_create_workflow - Create new workflows - n8n_update_partial_workflow - Update workflows using diff operations - n8n_validate_workflow - Validate workflow from n8n instance - n8n_trigger_webhook_workflow - Trigger workflow execution ## Performance Characteristics - Instant (<10ms): search_nodes, list_nodes, get_node_essentials - Fast (<100ms): validate_node_minimal, get_node_for_task - Moderate (100-500ms): validate_workflow, get_node_info - Network-dependent: All n8n_* tools For comprehensive documentation on any tool: tools_documentation({topic: "tool_name", depth: "full"})`; } const categories = getAllCategories(); return `# n8n MCP Tools - Complete Reference ## Important: Compatibility Notice ⚠️ This MCP server is tested with n8n version ${supportedN8nVersion}. Run n8n_health_check() to verify your n8n instance compatibility and API connectivity. ## Code Node Guides For Code node configuration, use these comprehensive guides: - tools_documentation({topic: "javascript_code_node_guide", depth: "full"}) - JavaScript patterns, n8n variables, error handling - tools_documentation({topic: "python_code_node_guide", depth: "full"}) - Python patterns, data access, debugging ## All Available Tools by Category ${categories.map(cat => { const tools = getToolsByCategory(cat); const categoryName = cat.charAt(0).toUpperCase() + cat.slice(1).replace('_', ' '); return `### ${categoryName} ${tools.map(toolName => { const tool = toolsDocumentation[toolName]; return `- **${toolName}**: ${tool.essentials.description}`; }).join('\n')}`; }).join('\n\n')} ## Usage Notes - All node types require the "nodes-base." or "nodes-langchain." prefix - Use get_node_essentials() first for most tasks (95% smaller than get_node_info) - Validation profiles: minimal (editing), runtime (default), strict (deployment) - n8n API tools only available when N8N_API_URL and N8N_API_KEY are configured For detailed documentation on any tool: tools_documentation({topic: "tool_name", depth: "full"})`; } export function searchToolDocumentation(keyword: string): string[] { const results: string[] = []; for (const [toolName, tool] of Object.entries(toolsDocumentation)) { const searchText = `${toolName} ${tool.essentials.description} ${tool.full.description}`.toLowerCase(); if (searchText.includes(keyword.toLowerCase())) { results.push(toolName); } } return results; } export function getToolsByCategory(category: string): string[] { return Object.entries(toolsDocumentation) .filter(([_, tool]) => tool.category === category) .map(([name, _]) => name); } export function getAllCategories(): string[] { const categories = new Set<string>(); Object.values(toolsDocumentation).forEach(tool => { categories.add(tool.category); }); return Array.from(categories); } // Special documentation topics function getJavaScriptCodeNodeGuide(depth: 'essentials' | 'full' = 'essentials'): string { if (depth === 'essentials') { return `# JavaScript Code Node Guide Essential patterns for JavaScript in n8n Code nodes. **Key Concepts**: - Access all items: \`$input.all()\` (not items[0]) - Current item data: \`$json\` - Return format: \`[{json: {...}}]\` (array of objects) **Available Helpers**: - \`$helpers.httpRequest()\` - Make HTTP requests - \`$jmespath()\` - Query JSON data - \`DateTime\` - Luxon for date handling **Common Patterns**: \`\`\`javascript // Process all items const allItems = $input.all(); return allItems.map(item => ({ json: { processed: true, original: item.json, timestamp: DateTime.now().toISO() } })); \`\`\` **Tips**: - Webhook data is under \`.body\` property - Use async/await for HTTP requests - Always return array format For full guide: tools_documentation({topic: "javascript_code_node_guide", depth: "full"})`; } // Full documentation return `# JavaScript Code Node Complete Guide Comprehensive guide for using JavaScript in n8n Code nodes. ## Data Access Patterns ### Accessing Input Data \`\`\`javascript // Get all items from previous node const allItems = $input.all(); // Get specific node's output const webhookData = $node["Webhook"].json; // Current item in loop const currentItem = $json; // First item only const firstItem = $input.first().json; \`\`\` ### Webhook Data Structure **CRITICAL**: Webhook data is nested under \`.body\`: \`\`\`javascript // WRONG - Won't work const data = $json.name; // CORRECT - Webhook data is under body const data = $json.body.name; \`\`\` ## Available Built-in Functions ### HTTP Requests \`\`\`javascript // Make HTTP request const response = await $helpers.httpRequest({ method: 'GET', url: 'https://api.example.com/data', headers: { 'Authorization': 'Bearer token' } }); \`\`\` ### Date/Time Handling \`\`\`javascript // Using Luxon DateTime const now = DateTime.now(); const formatted = now.toFormat('yyyy-MM-dd'); const iso = now.toISO(); const plus5Days = now.plus({ days: 5 }); \`\`\` ### JSON Querying \`\`\`javascript // JMESPath queries const result = $jmespath($json, "users[?age > 30].name"); \`\`\` ## Return Format Requirements ### Correct Format \`\`\`javascript // MUST return array of objects with json property return [{ json: { result: "success", data: processedData } }]; // Multiple items return items.map(item => ({ json: { id: item.id, processed: true } })); \`\`\` ### Binary Data \`\`\`javascript // Return with binary data return [{ json: { filename: "report.pdf" }, binary: { data: Buffer.from(pdfContent).toString('base64') } }]; \`\`\` ## Common Patterns ### Processing Webhook Data \`\`\`javascript // Extract webhook payload const webhookBody = $json.body; const { username, email, items } = webhookBody; // Process and return return [{ json: { username, email, itemCount: items.length, processedAt: DateTime.now().toISO() } }]; \`\`\` ### Aggregating Data \`\`\`javascript // Sum values across all items const allItems = $input.all(); const total = allItems.reduce((sum, item) => { return sum + (item.json.amount || 0); }, 0); return [{ json: { total, itemCount: allItems.length, average: total / allItems.length } }]; \`\`\` ### Error Handling \`\`\`javascript try { const response = await $helpers.httpRequest({ url: 'https://api.example.com/data' }); return [{ json: { success: true, data: response } }]; } catch (error) { return [{ json: { success: false, error: error.message } }]; } \`\`\` ## Available Node.js Modules - crypto (built-in) - Buffer - URL/URLSearchParams - Basic Node.js globals ## Common Pitfalls 1. Using \`items[0]\` instead of \`$input.all()\` 2. Forgetting webhook data is under \`.body\` 3. Returning plain objects instead of \`[{json: {...}}]\` 4. Using \`require()\` for external modules (not allowed) 5. Trying to use expression syntax \`{{}}\` inside code ## Best Practices 1. Always validate input data exists before accessing 2. Use try-catch for HTTP requests 3. Return early on validation failures 4. Keep code simple and readable 5. Use descriptive variable names ## Related Tools - get_node_essentials("nodes-base.code") - validate_node_operation() - python_code_node_guide (for Python syntax)`; } function getPythonCodeNodeGuide(depth: 'essentials' | 'full' = 'essentials'): string { if (depth === 'essentials') { return `# Python Code Node Guide Essential patterns for Python in n8n Code nodes. **Key Concepts**: - Access all items: \`_input.all()\` (not items[0]) - Current item data: \`_json\` - Return format: \`[{"json": {...}}]\` (list of dicts) **Limitations**: - No external libraries (no requests, pandas, numpy) - Use built-in functions only - No pip install available **Common Patterns**: \`\`\`python # Process all items all_items = _input.all() return [{ "json": { "processed": True, "count": len(all_items), "first_item": all_items[0]["json"] if all_items else None } }] \`\`\` **Tips**: - Webhook data is under ["body"] key - Use json module for parsing - datetime for date handling For full guide: tools_documentation({topic: "python_code_node_guide", depth: "full"})`; } // Full documentation return `# Python Code Node Complete Guide Comprehensive guide for using Python in n8n Code nodes. ## Data Access Patterns ### Accessing Input Data \`\`\`python # Get all items from previous node all_items = _input.all() # Get specific node's output (use _node) webhook_data = _node["Webhook"]["json"] # Current item in loop current_item = _json # First item only first_item = _input.first()["json"] \`\`\` ### Webhook Data Structure **CRITICAL**: Webhook data is nested under ["body"]: \`\`\`python # WRONG - Won't work data = _json["name"] # CORRECT - Webhook data is under body data = _json["body"]["name"] \`\`\` ## Available Built-in Modules ### Standard Library Only \`\`\`python import json import datetime import base64 import hashlib import urllib.parse import re import math import random \`\`\` ### Date/Time Handling \`\`\`python from datetime import datetime, timedelta # Current time now = datetime.now() iso_format = now.isoformat() # Date arithmetic future = now + timedelta(days=5) formatted = now.strftime("%Y-%m-%d") \`\`\` ### JSON Operations \`\`\`python # Parse JSON string data = json.loads(json_string) # Convert to JSON json_output = json.dumps({"key": "value"}) \`\`\` ## Return Format Requirements ### Correct Format \`\`\`python # MUST return list of dictionaries with "json" key return [{ "json": { "result": "success", "data": processed_data } }] # Multiple items return [ {"json": {"id": item["json"]["id"], "processed": True}} for item in all_items ] \`\`\` ### Binary Data \`\`\`python # Return with binary data import base64 return [{ "json": {"filename": "report.pdf"}, "binary": { "data": base64.b64encode(pdf_content).decode() } }] \`\`\` ## Common Patterns ### Processing Webhook Data \`\`\`python # Extract webhook payload webhook_body = _json["body"] username = webhook_body.get("username") email = webhook_body.get("email") items = webhook_body.get("items", []) # Process and return return [{ "json": { "username": username, "email": email, "item_count": len(items), "processed_at": datetime.now().isoformat() } }] \`\`\` ### Aggregating Data \`\`\`python # Sum values across all items all_items = _input.all() total = sum(item["json"].get("amount", 0) for item in all_items) return [{ "json": { "total": total, "item_count": len(all_items), "average": total / len(all_items) if all_items else 0 } }] \`\`\` ### Error Handling \`\`\`python try: # Process data webhook_data = _json["body"] result = process_data(webhook_data) return [{ "json": { "success": True, "data": result } }] except Exception as e: return [{ "json": { "success": False, "error": str(e) } }] \`\`\` ### Data Transformation \`\`\`python # Transform all items all_items = _input.all() transformed = [] for item in all_items: data = item["json"] transformed.append({ "json": { "id": data.get("id"), "name": data.get("name", "").upper(), "timestamp": datetime.now().isoformat(), "valid": bool(data.get("email")) } }) return transformed \`\`\` ## Limitations & Workarounds ### No External Libraries \`\`\`python # CANNOT USE: # import requests # Not available # import pandas # Not available # import numpy # Not available # WORKAROUND: Use JavaScript Code node for HTTP requests # Or use HTTP Request node before Code node \`\`\` ### HTTP Requests Alternative Since Python requests library is not available, use: 1. JavaScript Code node with $helpers.httpRequest() 2. HTTP Request node before your Python Code node 3. Webhook node to receive data ## Common Pitfalls 1. Trying to import external libraries (requests, pandas) 2. Using items[0] instead of _input.all() 3. Forgetting webhook data is under ["body"] 4. Returning dictionaries instead of [{"json": {...}}] 5. Not handling missing keys with .get() ## Best Practices 1. Always use .get() for dictionary access 2. Validate data before processing 3. Handle empty input arrays 4. Use list comprehensions for transformations 5. Return meaningful error messages ## Type Conversions \`\`\`python # String to number value = float(_json.get("amount", "0")) # Boolean conversion is_active = str(_json.get("active", "")).lower() == "true" # Safe JSON parsing try: data = json.loads(_json.get("json_string", "{}")) except json.JSONDecodeError: data = {} \`\`\` ## Related Tools - get_node_essentials("nodes-base.code") - validate_node_operation() - javascript_code_node_guide (for JavaScript syntax)`; } ``` -------------------------------------------------------------------------------- /tests/unit/parsers/node-parser.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NodeParser } from '@/parsers/node-parser'; import { PropertyExtractor } from '@/parsers/property-extractor'; import { programmaticNodeFactory, declarativeNodeFactory, triggerNodeFactory, webhookNodeFactory, aiToolNodeFactory, versionedNodeClassFactory, versionedNodeTypeClassFactory, malformedNodeFactory, nodeClassFactory, propertyFactory, stringPropertyFactory, optionsPropertyFactory } from '@tests/fixtures/factories/parser-node.factory'; // Mock PropertyExtractor vi.mock('@/parsers/property-extractor'); describe('NodeParser', () => { let parser: NodeParser; let mockPropertyExtractor: any; beforeEach(() => { vi.clearAllMocks(); // Setup mock property extractor mockPropertyExtractor = { extractProperties: vi.fn().mockReturnValue([]), extractCredentials: vi.fn().mockReturnValue([]), detectAIToolCapability: vi.fn().mockReturnValue(false), extractOperations: vi.fn().mockReturnValue([]) }; (PropertyExtractor as any).mockImplementation(() => mockPropertyExtractor); parser = new NodeParser(); }); describe('parse method', () => { it('should parse correctly when node is programmatic', () => { const nodeDefinition = programmaticNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); mockPropertyExtractor.extractProperties.mockReturnValue(nodeDefinition.properties); mockPropertyExtractor.extractCredentials.mockReturnValue(nodeDefinition.credentials); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result).toMatchObject({ style: 'programmatic', nodeType: `nodes-base.${nodeDefinition.name}`, displayName: nodeDefinition.displayName, description: nodeDefinition.description, category: nodeDefinition.group?.[0] || 'misc', packageName: 'n8n-nodes-base' }); // Check specific properties separately to avoid strict matching expect(result.isVersioned).toBe(false); expect(result.version).toBe(nodeDefinition.version?.toString() || '1'); expect(mockPropertyExtractor.extractProperties).toHaveBeenCalledWith(NodeClass); expect(mockPropertyExtractor.extractCredentials).toHaveBeenCalledWith(NodeClass); }); it('should parse correctly when node is declarative', () => { const nodeDefinition = declarativeNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.style).toBe('declarative'); expect(result.nodeType).toBe(`nodes-base.${nodeDefinition.name}`); }); it('should preserve type when package prefix is already included', () => { const nodeDefinition = programmaticNodeFactory.build({ name: 'nodes-base.slack' }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.nodeType).toBe('nodes-base.slack'); }); it('should set isTrigger flag when node is a trigger', () => { const nodeDefinition = triggerNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isTrigger).toBe(true); }); it('should set isWebhook flag when node is a webhook', () => { const nodeDefinition = webhookNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isWebhook).toBe(true); }); it('should set isAITool flag when node has AI capability', () => { const nodeDefinition = aiToolNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); mockPropertyExtractor.detectAIToolCapability.mockReturnValue(true); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isAITool).toBe(true); }); it('should parse correctly when node uses VersionedNodeType class', () => { // Create a simple versioned node class without modifying function properties const VersionedNodeClass = class VersionedNodeType { baseDescription = { name: 'versionedNode', displayName: 'Versioned Node', description: 'A versioned node', defaultVersion: 2 }; nodeVersions = { 1: { description: { properties: [] } }, 2: { description: { properties: [] } } }; currentVersion = 2; }; mockPropertyExtractor.extractProperties.mockReturnValue([ propertyFactory.build(), propertyFactory.build() ]); const result = parser.parse(VersionedNodeClass as any, 'n8n-nodes-base'); expect(result.isVersioned).toBe(true); expect(result.version).toBe('2'); expect(result.nodeType).toBe('nodes-base.versionedNode'); }); it('should parse correctly when node has nodeVersions property', () => { const versionedDef = versionedNodeClassFactory.build(); const NodeClass = class { nodeVersions = versionedDef.nodeVersions; baseDescription = versionedDef.baseDescription; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isVersioned).toBe(true); expect(result.version).toBe('2'); }); it('should use max version when version is an array', () => { const nodeDefinition = programmaticNodeFactory.build({ version: [1, 1.1, 1.2, 2] }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isVersioned).toBe(true); expect(result.version).toBe('2'); // Should return max version }); it('should throw error when node is missing name property', () => { const nodeDefinition = malformedNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); expect(() => parser.parse(NodeClass as any, 'n8n-nodes-base')).toThrow('Node is missing name property'); }); it('should use static description when instantiation fails', () => { const NodeClass = class { static description = programmaticNodeFactory.build(); constructor() { throw new Error('Cannot instantiate'); } }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.displayName).toBe(NodeClass.description.displayName); }); it('should extract category when using different property names', () => { const testCases = [ { group: ['transform'], expected: 'transform' }, { categories: ['output'], expected: 'output' }, { category: 'trigger', expected: 'trigger' }, { /* no category */ expected: 'misc' } ]; testCases.forEach(({ group, categories, category, expected }) => { const nodeDefinition = programmaticNodeFactory.build({ group, categories, category } as any); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.category).toBe(expected); }); }); it('should set isTrigger flag when node has polling property', () => { const nodeDefinition = programmaticNodeFactory.build({ polling: true }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isTrigger).toBe(true); }); it('should set isTrigger flag when node has eventTrigger property', () => { const nodeDefinition = programmaticNodeFactory.build({ eventTrigger: true }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isTrigger).toBe(true); }); it('should set isTrigger flag when node name contains trigger', () => { const nodeDefinition = programmaticNodeFactory.build({ name: 'myTrigger' }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isTrigger).toBe(true); }); it('should set isWebhook flag when node name contains webhook', () => { const nodeDefinition = programmaticNodeFactory.build({ name: 'customWebhook' }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isWebhook).toBe(true); }); it('should parse correctly when node is an instance object', () => { const nodeDefinition = programmaticNodeFactory.build(); const nodeInstance = { description: nodeDefinition }; mockPropertyExtractor.extractProperties.mockReturnValue(nodeDefinition.properties); const result = parser.parse(nodeInstance as any, 'n8n-nodes-base'); expect(result.displayName).toBe(nodeDefinition.displayName); }); it('should handle different package name formats', () => { const nodeDefinition = programmaticNodeFactory.build(); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const testCases = [ { packageName: '@n8n/n8n-nodes-langchain', expectedPrefix: 'nodes-langchain' }, { packageName: 'n8n-nodes-custom', expectedPrefix: 'nodes-custom' }, { packageName: 'custom-package', expectedPrefix: 'custom-package' } ]; testCases.forEach(({ packageName, expectedPrefix }) => { const result = parser.parse(NodeClass as any, packageName); expect(result.nodeType).toBe(`${expectedPrefix}.${nodeDefinition.name}`); }); }); }); describe('version extraction', () => { it('should prioritize currentVersion over description.defaultVersion', () => { const NodeClass = class { currentVersion = 2.2; // Should be returned description = { name: 'AI Agent', displayName: 'AI Agent', defaultVersion: 3 // Should be ignored when currentVersion exists }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('2.2'); }); it('should extract version from description.defaultVersion', () => { const NodeClass = class { description = { name: 'test', displayName: 'Test', defaultVersion: 3 }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('3'); }); it('should handle currentVersion = 0 correctly', () => { const NodeClass = class { currentVersion = 0; // Edge case: version 0 should be valid description = { name: 'test', displayName: 'Test', defaultVersion: 5 // Should be ignored }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('0'); }); it('should NOT extract version from non-existent baseDescription (legacy bug)', () => { const NodeClass = class { baseDescription = { // This property doesn't exist on VersionedNodeType! name: 'test', displayName: 'Test', defaultVersion: 3 }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('1'); // Should fallback to default }); it('should extract version from nodeVersions keys', () => { const NodeClass = class { description = { name: 'test', displayName: 'Test' }; nodeVersions = { 1: { description: {} }, 2: { description: {} }, 3: { description: {} } }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('3'); }); it('should extract version from instance nodeVersions', () => { const NodeClass = class { description = { name: 'test', displayName: 'Test' }; constructor() { (this as any).nodeVersions = { 1: { description: {} }, 2: { description: {} }, 4: { description: {} } }; } }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('4'); }); it('should handle version as number in description', () => { const nodeDefinition = programmaticNodeFactory.build({ version: 2 }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('2'); }); it('should handle version as string in description', () => { const nodeDefinition = programmaticNodeFactory.build({ version: '1.5' as any }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('1.5'); }); it('should default to version 1 when no version found', () => { const nodeDefinition = programmaticNodeFactory.build(); delete (nodeDefinition as any).version; const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.version).toBe('1'); }); }); describe('versioned node detection', () => { it('should detect versioned nodes with nodeVersions', () => { const NodeClass = class { description = { name: 'test', displayName: 'Test' }; nodeVersions = { 1: {}, 2: {} }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isVersioned).toBe(true); }); it('should detect versioned nodes with defaultVersion', () => { const NodeClass = class { baseDescription = { name: 'test', displayName: 'Test', defaultVersion: 2 }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isVersioned).toBe(true); }); it('should detect versioned nodes with version array in instance', () => { const NodeClass = class { description = { name: 'test', displayName: 'Test', version: [1, 1.1, 2] }; }; const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isVersioned).toBe(true); }); it('should not detect non-versioned nodes as versioned', () => { const nodeDefinition = programmaticNodeFactory.build({ version: 1 }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isVersioned).toBe(false); }); }); describe('edge cases', () => { it('should handle null/undefined description gracefully', () => { const NodeClass = class { description = null; }; expect(() => parser.parse(NodeClass as any, 'n8n-nodes-base')).toThrow(); }); it('should handle empty routing object for declarative nodes', () => { const nodeDefinition = declarativeNodeFactory.build({ routing: {} as any }); const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.style).toBe('declarative'); }); it('should handle complex nested versioned structure', () => { const NodeClass = class VersionedNodeType { constructor() { (this as any).baseDescription = { name: 'complex', displayName: 'Complex Node', defaultVersion: 3 }; (this as any).nodeVersions = { 1: { description: { properties: [] } }, 2: { description: { properties: [] } }, 3: { description: { properties: [] } } }; } }; // Override constructor name check Object.defineProperty(NodeClass.prototype.constructor, 'name', { value: 'VersionedNodeType' }); const result = parser.parse(NodeClass as any, 'n8n-nodes-base'); expect(result.isVersioned).toBe(true); expect(result.version).toBe('3'); }); }); }); ``` -------------------------------------------------------------------------------- /src/services/node-similarity-service.ts: -------------------------------------------------------------------------------- ```typescript import { NodeRepository } from '../database/node-repository'; import { logger } from '../utils/logger'; export interface NodeSuggestion { nodeType: string; displayName: string; confidence: number; reason: string; category?: string; description?: string; } export interface SimilarityScore { nameSimilarity: number; categoryMatch: number; packageMatch: number; patternMatch: number; totalScore: number; } export interface CommonMistakePattern { pattern: string; suggestion: string; confidence: number; reason: string; } export class NodeSimilarityService { // Constants to avoid magic numbers private static readonly SCORING_THRESHOLD = 50; // Minimum 50% confidence to suggest private static readonly TYPO_EDIT_DISTANCE = 2; // Max 2 character differences for typo detection private static readonly SHORT_SEARCH_LENGTH = 5; // Searches ≤5 chars need special handling private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes private static readonly AUTO_FIX_CONFIDENCE = 0.9; // 90% confidence for auto-fix private repository: NodeRepository; private commonMistakes: Map<string, CommonMistakePattern[]>; private nodeCache: any[] | null = null; private cacheExpiry: number = 0; private cacheVersion: number = 0; // Track cache version for invalidation constructor(repository: NodeRepository) { this.repository = repository; this.commonMistakes = this.initializeCommonMistakes(); } /** * Initialize common mistake patterns * Using safer string-based patterns instead of complex regex to avoid ReDoS */ private initializeCommonMistakes(): Map<string, CommonMistakePattern[]> { const patterns = new Map<string, CommonMistakePattern[]>(); // Case variations - using exact string matching (case-insensitive) patterns.set('case_variations', [ { pattern: 'httprequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Incorrect capitalization' }, { pattern: 'webhook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Incorrect capitalization' }, { pattern: 'slack', suggestion: 'nodes-base.slack', confidence: 0.9, reason: 'Missing package prefix' }, { pattern: 'gmail', suggestion: 'nodes-base.gmail', confidence: 0.9, reason: 'Missing package prefix' }, { pattern: 'googlesheets', suggestion: 'nodes-base.googleSheets', confidence: 0.9, reason: 'Missing package prefix' }, { pattern: 'telegram', suggestion: 'nodes-base.telegram', confidence: 0.9, reason: 'Missing package prefix' }, ]); // Specific case variations that are common patterns.set('specific_variations', [ { pattern: 'HttpRequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Incorrect capitalization' }, { pattern: 'HTTPRequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Common capitalization mistake' }, { pattern: 'Webhook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Incorrect capitalization' }, { pattern: 'WebHook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Common capitalization mistake' }, ]); // Deprecated package prefixes patterns.set('deprecated_prefixes', [ { pattern: 'n8n-nodes-base.', suggestion: 'nodes-base.', confidence: 0.95, reason: 'Full package name used instead of short form' }, { pattern: '@n8n/n8n-nodes-langchain.', suggestion: 'nodes-langchain.', confidence: 0.95, reason: 'Full package name used instead of short form' }, ]); // Common typos - exact matches patterns.set('typos', [ { pattern: 'htprequest', suggestion: 'nodes-base.httpRequest', confidence: 0.8, reason: 'Likely typo' }, { pattern: 'httpreqest', suggestion: 'nodes-base.httpRequest', confidence: 0.8, reason: 'Likely typo' }, { pattern: 'webook', suggestion: 'nodes-base.webhook', confidence: 0.8, reason: 'Likely typo' }, { pattern: 'slak', suggestion: 'nodes-base.slack', confidence: 0.8, reason: 'Likely typo' }, { pattern: 'googlesheets', suggestion: 'nodes-base.googleSheets', confidence: 0.8, reason: 'Likely typo' }, ]); // AI/LangChain specific patterns.set('ai_nodes', [ { pattern: 'openai', suggestion: 'nodes-langchain.openAi', confidence: 0.85, reason: 'AI node - incorrect package' }, { pattern: 'nodes-base.openai', suggestion: 'nodes-langchain.openAi', confidence: 0.9, reason: 'Wrong package - OpenAI is in LangChain package' }, { pattern: 'chatopenai', suggestion: 'nodes-langchain.lmChatOpenAi', confidence: 0.85, reason: 'LangChain node naming convention' }, { pattern: 'vectorstore', suggestion: 'nodes-langchain.vectorStoreInMemory', confidence: 0.7, reason: 'Generic vector store reference' }, ]); return patterns; } /** * Check if a type is a common node name without prefix */ private isCommonNodeWithoutPrefix(type: string): string | null { const commonNodes: Record<string, string> = { 'httprequest': 'nodes-base.httpRequest', 'webhook': 'nodes-base.webhook', 'slack': 'nodes-base.slack', 'gmail': 'nodes-base.gmail', 'googlesheets': 'nodes-base.googleSheets', 'telegram': 'nodes-base.telegram', 'discord': 'nodes-base.discord', 'notion': 'nodes-base.notion', 'airtable': 'nodes-base.airtable', 'postgres': 'nodes-base.postgres', 'mysql': 'nodes-base.mySql', 'mongodb': 'nodes-base.mongoDb', }; const normalized = type.toLowerCase(); return commonNodes[normalized] || null; } /** * Find similar nodes for an invalid type */ async findSimilarNodes(invalidType: string, limit: number = 5): Promise<NodeSuggestion[]> { if (!invalidType || invalidType.trim() === '') { return []; } const suggestions: NodeSuggestion[] = []; // First, check for exact common mistakes const mistakeSuggestion = this.checkCommonMistakes(invalidType); if (mistakeSuggestion) { suggestions.push(mistakeSuggestion); } // Get all nodes (with caching) const allNodes = await this.getCachedNodes(); // Calculate similarity scores for all nodes const scores = allNodes.map(node => ({ node, score: this.calculateSimilarityScore(invalidType, node) })); // Sort by total score and filter high scores scores.sort((a, b) => b.score.totalScore - a.score.totalScore); // Add top suggestions (excluding already added exact matches) for (const { node, score } of scores) { if (suggestions.some(s => s.nodeType === node.nodeType)) { continue; } if (score.totalScore >= NodeSimilarityService.SCORING_THRESHOLD) { suggestions.push(this.createSuggestion(node, score)); } if (suggestions.length >= limit) { break; } } return suggestions; } /** * Check for common mistake patterns (ReDoS-safe implementation) */ private checkCommonMistakes(invalidType: string): NodeSuggestion | null { const cleanType = invalidType.trim(); const lowerType = cleanType.toLowerCase(); // First check for common nodes without prefix const commonNodeSuggestion = this.isCommonNodeWithoutPrefix(cleanType); if (commonNodeSuggestion) { const node = this.repository.getNode(commonNodeSuggestion); if (node) { return { nodeType: commonNodeSuggestion, displayName: node.displayName, confidence: 0.9, reason: 'Missing package prefix', category: node.category, description: node.description }; } } // Check deprecated prefixes (string-based, no regex) for (const [category, patterns] of this.commonMistakes) { if (category === 'deprecated_prefixes') { for (const pattern of patterns) { if (cleanType.startsWith(pattern.pattern)) { const actualSuggestion = cleanType.replace(pattern.pattern, pattern.suggestion); const node = this.repository.getNode(actualSuggestion); if (node) { return { nodeType: actualSuggestion, displayName: node.displayName, confidence: pattern.confidence, reason: pattern.reason, category: node.category, description: node.description }; } } } } } // Check exact matches for typos and variations for (const [category, patterns] of this.commonMistakes) { if (category === 'deprecated_prefixes') continue; // Already handled for (const pattern of patterns) { // Simple string comparison (case-sensitive for specific_variations) const match = category === 'specific_variations' ? cleanType === pattern.pattern : lowerType === pattern.pattern.toLowerCase(); if (match && pattern.suggestion) { const node = this.repository.getNode(pattern.suggestion); if (node) { return { nodeType: pattern.suggestion, displayName: node.displayName, confidence: pattern.confidence, reason: pattern.reason, category: node.category, description: node.description }; } } } } return null; } /** * Calculate multi-factor similarity score */ private calculateSimilarityScore(invalidType: string, node: any): SimilarityScore { const cleanInvalid = this.normalizeNodeType(invalidType); const cleanValid = this.normalizeNodeType(node.nodeType); const displayNameClean = this.normalizeNodeType(node.displayName); // Special handling for very short search terms (e.g., "http", "sheet") const isShortSearch = invalidType.length <= NodeSimilarityService.SHORT_SEARCH_LENGTH; // Name similarity (40% weight) let nameSimilarity = Math.max( this.getStringSimilarity(cleanInvalid, cleanValid), this.getStringSimilarity(cleanInvalid, displayNameClean) ) * 40; // For short searches that are substrings, give a small name similarity boost if (isShortSearch && (cleanValid.includes(cleanInvalid) || displayNameClean.includes(cleanInvalid))) { nameSimilarity = Math.max(nameSimilarity, 10); } // Category match (20% weight) let categoryMatch = 0; if (node.category) { const categoryClean = this.normalizeNodeType(node.category); if (cleanInvalid.includes(categoryClean) || categoryClean.includes(cleanInvalid)) { categoryMatch = 20; } } // Package match (15% weight) let packageMatch = 0; const invalidParts = cleanInvalid.split(/[.-]/); const validParts = cleanValid.split(/[.-]/); if (invalidParts[0] === validParts[0]) { packageMatch = 15; } // Pattern match (25% weight) let patternMatch = 0; // Check if it's a substring match if (cleanValid.includes(cleanInvalid) || displayNameClean.includes(cleanInvalid)) { // Boost score significantly for short searches that are exact substring matches // Short searches need more boost to reach the 50 threshold patternMatch = isShortSearch ? 45 : 25; } else if (this.getEditDistance(cleanInvalid, cleanValid) <= NodeSimilarityService.TYPO_EDIT_DISTANCE) { // Small edit distance indicates likely typo patternMatch = 20; } else if (this.getEditDistance(cleanInvalid, displayNameClean) <= NodeSimilarityService.TYPO_EDIT_DISTANCE) { patternMatch = 18; } // For very short searches, also check if the search term appears at the start if (isShortSearch && (cleanValid.startsWith(cleanInvalid) || displayNameClean.startsWith(cleanInvalid))) { patternMatch = Math.max(patternMatch, 40); } const totalScore = nameSimilarity + categoryMatch + packageMatch + patternMatch; return { nameSimilarity, categoryMatch, packageMatch, patternMatch, totalScore }; } /** * Create a suggestion object from node and score */ private createSuggestion(node: any, score: SimilarityScore): NodeSuggestion { let reason = 'Similar node'; if (score.patternMatch >= 20) { reason = 'Name similarity'; } else if (score.categoryMatch >= 15) { reason = 'Same category'; } else if (score.packageMatch >= 10) { reason = 'Same package'; } // Calculate confidence (0-1 scale) const confidence = Math.min(score.totalScore / 100, 1); return { nodeType: node.nodeType, displayName: node.displayName, confidence, reason, category: node.category, description: node.description }; } /** * Normalize node type for comparison */ private normalizeNodeType(type: string): string { return type .toLowerCase() .replace(/[^a-z0-9]/g, '') .trim(); } /** * Calculate string similarity (0-1) */ private getStringSimilarity(s1: string, s2: string): number { if (s1 === s2) return 1; if (!s1 || !s2) return 0; const distance = this.getEditDistance(s1, s2); const maxLen = Math.max(s1.length, s2.length); return 1 - (distance / maxLen); } /** * Calculate Levenshtein distance with optimizations * - Early termination when difference exceeds threshold * - Space-optimized to use only two rows instead of full matrix * - Fast path for identical or vastly different strings */ private getEditDistance(s1: string, s2: string, maxDistance: number = 5): number { // Fast path: identical strings if (s1 === s2) return 0; const m = s1.length; const n = s2.length; // Fast path: length difference exceeds threshold const lengthDiff = Math.abs(m - n); if (lengthDiff > maxDistance) return maxDistance + 1; // Fast path: empty strings if (m === 0) return n; if (n === 0) return m; // Space optimization: only need previous and current row let prev = Array(n + 1).fill(0).map((_, i) => i); for (let i = 1; i <= m; i++) { const curr = [i]; let minInRow = i; for (let j = 1; j <= n; j++) { const cost = s1[i - 1] === s2[j - 1] ? 0 : 1; const val = Math.min( curr[j - 1] + 1, // deletion prev[j] + 1, // insertion prev[j - 1] + cost // substitution ); curr.push(val); minInRow = Math.min(minInRow, val); } // Early termination: if minimum in this row exceeds threshold if (minInRow > maxDistance) { return maxDistance + 1; } prev = curr; } return prev[n]; } /** * Get cached nodes or fetch from repository * Implements proper cache invalidation with version tracking */ private async getCachedNodes(): Promise<any[]> { const now = Date.now(); if (!this.nodeCache || now > this.cacheExpiry) { try { const newNodes = this.repository.getAllNodes(); // Only update cache if we got valid data if (newNodes && newNodes.length > 0) { this.nodeCache = newNodes; this.cacheExpiry = now + NodeSimilarityService.CACHE_DURATION_MS; this.cacheVersion++; logger.debug('Node cache refreshed', { count: newNodes.length, version: this.cacheVersion }); } else if (this.nodeCache) { // Return stale cache if new fetch returned empty logger.warn('Node fetch returned empty, using stale cache'); } } catch (error) { logger.error('Failed to fetch nodes for similarity service', error); // Return stale cache on error if available if (this.nodeCache) { logger.info('Using stale cache due to fetch error'); return this.nodeCache; } return []; } } return this.nodeCache || []; } /** * Invalidate the cache (e.g., after database updates) */ public invalidateCache(): void { this.nodeCache = null; this.cacheExpiry = 0; this.cacheVersion++; logger.debug('Node cache invalidated', { version: this.cacheVersion }); } /** * Clear and refresh cache immediately */ public async refreshCache(): Promise<void> { this.invalidateCache(); await this.getCachedNodes(); } /** * Format suggestions into a user-friendly message */ formatSuggestionMessage(suggestions: NodeSuggestion[], invalidType: string): string { if (suggestions.length === 0) { return `Unknown node type: "${invalidType}". No similar nodes found.`; } let message = `Unknown node type: "${invalidType}"\n\nDid you mean one of these?\n`; for (const suggestion of suggestions) { const confidence = Math.round(suggestion.confidence * 100); message += `• ${suggestion.nodeType} (${confidence}% match)`; if (suggestion.displayName) { message += ` - ${suggestion.displayName}`; } message += `\n → ${suggestion.reason}`; if (suggestion.confidence >= 0.9) { message += ' (can be auto-fixed)'; } message += '\n'; } return message; } /** * Check if a suggestion is high confidence for auto-fixing */ isAutoFixable(suggestion: NodeSuggestion): boolean { return suggestion.confidence >= NodeSimilarityService.AUTO_FIX_CONFIDENCE; } /** * Clear the node cache (useful after database updates) * @deprecated Use invalidateCache() instead for proper version tracking */ clearCache(): void { this.invalidateCache(); } } ``` -------------------------------------------------------------------------------- /tests/unit/telemetry/event-validator.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { z } from 'zod'; import { TelemetryEventValidator, telemetryEventSchema, workflowTelemetrySchema } from '../../../src/telemetry/event-validator'; import { TelemetryEvent, WorkflowTelemetry } from '../../../src/telemetry/telemetry-types'; // Mock logger to avoid console output in tests vi.mock('../../../src/utils/logger', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), } })); describe('TelemetryEventValidator', () => { let validator: TelemetryEventValidator; beforeEach(() => { validator = new TelemetryEventValidator(); vi.clearAllMocks(); }); describe('validateEvent()', () => { it('should validate a basic valid event', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'tool_used', properties: { tool: 'httpRequest', success: true, duration: 500 } }; const result = validator.validateEvent(event); expect(result).toEqual(event); }); it('should validate event with specific schema for tool_used', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'tool_used', properties: { tool: 'httpRequest', success: true, duration: 500 } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(result?.properties.tool).toBe('httpRequest'); expect(result?.properties.success).toBe(true); expect(result?.properties.duration).toBe(500); }); it('should validate search_query event with specific schema', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'search_query', properties: { query: 'test query', resultsFound: 5, searchType: 'nodes', hasResults: true, isZeroResults: false } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(result?.properties.query).toBe('test query'); expect(result?.properties.resultsFound).toBe(5); expect(result?.properties.hasResults).toBe(true); }); it('should validate performance_metric event with specific schema', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'performance_metric', properties: { operation: 'database_query', duration: 1500, isSlow: true, isVerySlow: false, metadata: { table: 'nodes' } } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(result?.properties.operation).toBe('database_query'); expect(result?.properties.duration).toBe(1500); expect(result?.properties.isSlow).toBe(true); }); it('should sanitize sensitive data from properties', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'generic_event', properties: { description: 'Visit https://example.com/secret and [email protected] with key abcdef123456789012345678901234567890', apiKey: 'super-secret-key-12345678901234567890', normalProp: 'normal value' } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(result?.properties.description).toBe('Visit [URL] and [EMAIL] with key [KEY]'); expect(result?.properties.normalProp).toBe('normal value'); expect(result?.properties).not.toHaveProperty('apiKey'); // Should be filtered out }); it('should handle nested object sanitization with depth limit', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'nested_event', properties: { nested: { level1: { level2: { level3: { level4: 'should be truncated', apiKey: 'secret123', description: 'Visit https://example.com' }, description: 'Visit https://another.com' } } } } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(result?.properties.nested.level1.level2.level3).toBe('[NESTED]'); expect(result?.properties.nested.level1.level2.description).toBe('Visit [URL]'); }); it('should handle array sanitization with size limit', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'array_event', properties: { items: Array.from({ length: 15 }, (_, i) => ({ id: i, description: 'Visit https://example.com', value: `item-${i}` })) } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(Array.isArray(result?.properties.items)).toBe(true); expect(result?.properties.items.length).toBe(10); // Should be limited to 10 }); it('should reject events with invalid user_id', () => { const event: TelemetryEvent = { user_id: '', // Empty string event: 'test_event', properties: {} }; const result = validator.validateEvent(event); expect(result).toBeNull(); }); it('should reject events with invalid event name', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'invalid-event-name!@#', // Invalid characters properties: {} }; const result = validator.validateEvent(event); expect(result).toBeNull(); }); it('should reject tool_used event with invalid properties', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'tool_used', properties: { tool: 'test', success: 'not-a-boolean', // Should be boolean duration: -1 // Should be positive } }; const result = validator.validateEvent(event); expect(result).toBeNull(); }); it('should filter out sensitive keys from properties', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'sensitive_event', properties: { password: 'secret123', token: 'bearer-token', apikey: 'api-key-value', secret: 'secret-value', credential: 'cred-value', auth: 'auth-header', url: 'https://example.com', endpoint: 'api.example.com', host: 'localhost', database: 'prod-db', normalProp: 'safe-value', count: 42, enabled: true } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(result?.properties).not.toHaveProperty('password'); expect(result?.properties).not.toHaveProperty('token'); expect(result?.properties).not.toHaveProperty('apikey'); expect(result?.properties).not.toHaveProperty('secret'); expect(result?.properties).not.toHaveProperty('credential'); expect(result?.properties).not.toHaveProperty('auth'); expect(result?.properties).not.toHaveProperty('url'); expect(result?.properties).not.toHaveProperty('endpoint'); expect(result?.properties).not.toHaveProperty('host'); expect(result?.properties).not.toHaveProperty('database'); expect(result?.properties.normalProp).toBe('safe-value'); expect(result?.properties.count).toBe(42); expect(result?.properties.enabled).toBe(true); }); it('should handle validation_details event schema', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'validation_details', properties: { nodeType: 'nodes-base.httpRequest', errorType: 'required_field_missing', errorCategory: 'validation_error', details: { field: 'url' } } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(result?.properties.nodeType).toBe('nodes-base.httpRequest'); expect(result?.properties.errorType).toBe('required_field_missing'); }); it('should handle null and undefined values', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'null_event', properties: { nullValue: null, undefinedValue: undefined, normalValue: 'test' } }; const result = validator.validateEvent(event); expect(result).not.toBeNull(); expect(result?.properties.nullValue).toBeNull(); expect(result?.properties.undefinedValue).toBeNull(); expect(result?.properties.normalValue).toBe('test'); }); }); describe('validateWorkflow()', () => { it('should validate a valid workflow', () => { const workflow: WorkflowTelemetry = { user_id: 'user123', workflow_hash: 'hash123', node_count: 3, node_types: ['webhook', 'httpRequest', 'set'], has_trigger: true, has_webhook: true, complexity: 'medium', sanitized_workflow: { nodes: [ { id: '1', type: 'webhook' }, { id: '2', type: 'httpRequest' }, { id: '3', type: 'set' } ], connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } } } }; const result = validator.validateWorkflow(workflow); expect(result).toEqual(workflow); }); it('should reject workflow with too many nodes', () => { const workflow: WorkflowTelemetry = { user_id: 'user123', workflow_hash: 'hash123', node_count: 1001, // Over limit node_types: ['webhook'], has_trigger: true, has_webhook: true, complexity: 'complex', sanitized_workflow: { nodes: [], connections: {} } }; const result = validator.validateWorkflow(workflow); expect(result).toBeNull(); }); it('should reject workflow with invalid complexity', () => { const workflow = { user_id: 'user123', workflow_hash: 'hash123', node_count: 3, node_types: ['webhook'], has_trigger: true, has_webhook: true, complexity: 'invalid' as any, // Invalid complexity sanitized_workflow: { nodes: [], connections: {} } }; const result = validator.validateWorkflow(workflow); expect(result).toBeNull(); }); it('should reject workflow with too many node types', () => { const workflow: WorkflowTelemetry = { user_id: 'user123', workflow_hash: 'hash123', node_count: 3, node_types: Array.from({ length: 101 }, (_, i) => `node-${i}`), // Over limit has_trigger: true, has_webhook: true, complexity: 'complex', sanitized_workflow: { nodes: [], connections: {} } }; const result = validator.validateWorkflow(workflow); expect(result).toBeNull(); }); }); describe('getStats()', () => { it('should track validation statistics', () => { const validEvent: TelemetryEvent = { user_id: 'user123', event: 'valid_event', properties: {} }; const invalidEvent: TelemetryEvent = { user_id: '', // Invalid event: 'invalid_event', properties: {} }; validator.validateEvent(validEvent); validator.validateEvent(validEvent); validator.validateEvent(invalidEvent); const stats = validator.getStats(); expect(stats.successes).toBe(2); expect(stats.errors).toBe(1); expect(stats.total).toBe(3); expect(stats.errorRate).toBeCloseTo(0.333, 3); }); it('should handle division by zero in error rate', () => { const stats = validator.getStats(); expect(stats.errorRate).toBe(0); }); }); describe('resetStats()', () => { it('should reset validation statistics', () => { const validEvent: TelemetryEvent = { user_id: 'user123', event: 'valid_event', properties: {} }; validator.validateEvent(validEvent); validator.resetStats(); const stats = validator.getStats(); expect(stats.successes).toBe(0); expect(stats.errors).toBe(0); expect(stats.total).toBe(0); expect(stats.errorRate).toBe(0); }); }); describe('Schema validation', () => { describe('telemetryEventSchema', () => { it('should validate with created_at timestamp', () => { const event = { user_id: 'user123', event: 'test_event', properties: {}, created_at: '2024-01-01T00:00:00Z' }; const result = telemetryEventSchema.safeParse(event); expect(result.success).toBe(true); }); it('should reject invalid datetime format', () => { const event = { user_id: 'user123', event: 'test_event', properties: {}, created_at: 'invalid-date' }; const result = telemetryEventSchema.safeParse(event); expect(result.success).toBe(false); }); it('should enforce user_id length limits', () => { const longUserId = 'a'.repeat(65); const event = { user_id: longUserId, event: 'test_event', properties: {} }; const result = telemetryEventSchema.safeParse(event); expect(result.success).toBe(false); }); it('should enforce event name regex pattern', () => { const event = { user_id: 'user123', event: 'invalid event name with spaces!', properties: {} }; const result = telemetryEventSchema.safeParse(event); expect(result.success).toBe(false); }); }); describe('workflowTelemetrySchema', () => { it('should enforce node array size limits', () => { const workflow = { user_id: 'user123', workflow_hash: 'hash123', node_count: 3, node_types: ['test'], has_trigger: true, has_webhook: false, complexity: 'simple', sanitized_workflow: { nodes: Array.from({ length: 1001 }, (_, i) => ({ id: i })), // Over limit connections: {} } }; const result = workflowTelemetrySchema.safeParse(workflow); expect(result.success).toBe(false); }); it('should validate with optional created_at', () => { const workflow = { user_id: 'user123', workflow_hash: 'hash123', node_count: 1, node_types: ['webhook'], has_trigger: true, has_webhook: true, complexity: 'simple', sanitized_workflow: { nodes: [{ id: '1' }], connections: {} }, created_at: '2024-01-01T00:00:00Z' }; const result = workflowTelemetrySchema.safeParse(workflow); expect(result.success).toBe(true); }); }); }); describe('String sanitization edge cases', () => { it('should handle multiple URLs in same string', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'test_event', properties: { description: 'Visit https://example.com or http://test.com for more info' } }; const result = validator.validateEvent(event); expect(result?.properties.description).toBe('Visit [URL] or [URL] for more info'); }); it('should handle mixed sensitive content', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'test_event', properties: { message: 'Contact [email protected] at https://secure.com with key abc123def456ghi789jkl012mno345pqr' } }; const result = validator.validateEvent(event); expect(result?.properties.message).toBe('Contact [EMAIL] at [URL] with key [KEY]'); }); it('should preserve non-sensitive content', () => { const event: TelemetryEvent = { user_id: 'user123', event: 'test_event', properties: { status: 'success', count: 42, enabled: true, short_id: 'abc123' // Too short to be considered a key } }; const result = validator.validateEvent(event); expect(result?.properties.status).toBe('success'); expect(result?.properties.count).toBe(42); expect(result?.properties.enabled).toBe(true); expect(result?.properties.short_id).toBe('abc123'); }); }); describe('Error handling', () => { it('should handle Zod parsing errors gracefully', () => { const invalidEvent = { user_id: 123, // Should be string event: 'test_event', properties: {} }; const result = validator.validateEvent(invalidEvent as any); expect(result).toBeNull(); }); it('should handle unexpected errors during validation', () => { const eventWithCircularRef: any = { user_id: 'user123', event: 'test_event', properties: {} }; // Create circular reference eventWithCircularRef.properties.self = eventWithCircularRef; const result = validator.validateEvent(eventWithCircularRef); // Should handle gracefully and not throw expect(result).not.toThrow; }); }); }); ``` -------------------------------------------------------------------------------- /scripts/test-release-automation.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node /** * Test script for release automation * Validates the release workflow components locally */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // Color codes for output const colors = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }; function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`); } function header(title) { log(`\n${'='.repeat(60)}`, 'cyan'); log(`🧪 ${title}`, 'cyan'); log(`${'='.repeat(60)}`, 'cyan'); } function section(title) { log(`\n📋 ${title}`, 'blue'); log(`${'-'.repeat(40)}`, 'blue'); } function success(message) { log(`✅ ${message}`, 'green'); } function warning(message) { log(`⚠️ ${message}`, 'yellow'); } function error(message) { log(`❌ ${message}`, 'red'); } function info(message) { log(`ℹ️ ${message}`, 'blue'); } class ReleaseAutomationTester { constructor() { this.rootDir = path.resolve(__dirname, '..'); this.errors = []; this.warnings = []; } /** * Test if required files exist */ testFileExistence() { section('Testing File Existence'); const requiredFiles = [ 'package.json', 'package.runtime.json', 'docs/CHANGELOG.md', '.github/workflows/release.yml', 'scripts/sync-runtime-version.js', 'scripts/publish-npm.sh' ]; for (const file of requiredFiles) { const filePath = path.join(this.rootDir, file); if (fs.existsSync(filePath)) { success(`Found: ${file}`); } else { error(`Missing: ${file}`); this.errors.push(`Missing required file: ${file}`); } } } /** * Test version detection logic */ testVersionDetection() { section('Testing Version Detection'); try { const packageJson = require(path.join(this.rootDir, 'package.json')); const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json')); success(`Package.json version: ${packageJson.version}`); success(`Runtime package version: ${runtimeJson.version}`); if (packageJson.version === runtimeJson.version) { success('Version sync: Both versions match'); } else { warning('Version sync: Versions do not match - run sync:runtime-version'); this.warnings.push('Package versions are not synchronized'); } // Test semantic version format const semverRegex = /^\d+\.\d+\.\d+(?:-[\w\.-]+)?(?:\+[\w\.-]+)?$/; if (semverRegex.test(packageJson.version)) { success(`Version format: Valid semantic version (${packageJson.version})`); } else { error(`Version format: Invalid semantic version (${packageJson.version})`); this.errors.push('Invalid semantic version format'); } } catch (err) { error(`Version detection failed: ${err.message}`); this.errors.push(`Version detection error: ${err.message}`); } } /** * Test changelog parsing */ testChangelogParsing() { section('Testing Changelog Parsing'); try { const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md'); if (!fs.existsSync(changelogPath)) { error('Changelog file not found'); this.errors.push('Missing changelog file'); return; } const changelogContent = fs.readFileSync(changelogPath, 'utf8'); const packageJson = require(path.join(this.rootDir, 'package.json')); const currentVersion = packageJson.version; // Check if current version exists in changelog const versionRegex = new RegExp(`^## \\[${currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm'); if (versionRegex.test(changelogContent)) { success(`Changelog entry found for version ${currentVersion}`); // Test extraction logic (simplified version of the GitHub Actions script) const lines = changelogContent.split('\n'); let startIndex = -1; let endIndex = -1; for (let i = 0; i < lines.length; i++) { if (versionRegex.test(lines[i])) { startIndex = i; break; } } if (startIndex !== -1) { // Find the end of this version's section for (let i = startIndex + 1; i < lines.length; i++) { if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) { endIndex = i; break; } } if (endIndex === -1) { endIndex = lines.length; } const sectionLines = lines.slice(startIndex + 1, endIndex); const contentLines = sectionLines.filter(line => line.trim() !== ''); if (contentLines.length > 0) { success(`Changelog content extracted: ${contentLines.length} lines`); info(`Preview: ${contentLines[0].substring(0, 100)}...`); } else { warning('Changelog section appears to be empty'); this.warnings.push(`Empty changelog section for version ${currentVersion}`); } } } else { warning(`No changelog entry found for current version ${currentVersion}`); this.warnings.push(`Missing changelog entry for version ${currentVersion}`); } // Check changelog format if (changelogContent.includes('## [Unreleased]')) { success('Changelog format: Contains Unreleased section'); } else { warning('Changelog format: Missing Unreleased section'); } if (changelogContent.includes('Keep a Changelog')) { success('Changelog format: Follows Keep a Changelog format'); } else { warning('Changelog format: Does not reference Keep a Changelog'); } } catch (err) { error(`Changelog parsing failed: ${err.message}`); this.errors.push(`Changelog parsing error: ${err.message}`); } } /** * Test build process */ testBuildProcess() { section('Testing Build Process'); try { // Check if dist directory exists const distPath = path.join(this.rootDir, 'dist'); if (fs.existsSync(distPath)) { success('Build output: dist directory exists'); // Check for key build files const keyFiles = [ 'dist/index.js', 'dist/mcp/index.js', 'dist/mcp/server.js' ]; for (const file of keyFiles) { const filePath = path.join(this.rootDir, file); if (fs.existsSync(filePath)) { success(`Build file: ${file} exists`); } else { warning(`Build file: ${file} missing - run 'npm run build'`); this.warnings.push(`Missing build file: ${file}`); } } } else { warning('Build output: dist directory missing - run "npm run build"'); this.warnings.push('Missing build output'); } // Check database const dbPath = path.join(this.rootDir, 'data/nodes.db'); if (fs.existsSync(dbPath)) { const stats = fs.statSync(dbPath); success(`Database: nodes.db exists (${Math.round(stats.size / 1024 / 1024)}MB)`); } else { warning('Database: nodes.db missing - run "npm run rebuild"'); this.warnings.push('Missing database file'); } } catch (err) { error(`Build process test failed: ${err.message}`); this.errors.push(`Build process error: ${err.message}`); } } /** * Test npm publish preparation */ testNpmPublishPrep() { section('Testing NPM Publish Preparation'); try { const packageJson = require(path.join(this.rootDir, 'package.json')); const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json')); // Check package.json fields const requiredFields = ['name', 'version', 'description', 'main', 'bin']; for (const field of requiredFields) { if (packageJson[field]) { success(`Package field: ${field} is present`); } else { error(`Package field: ${field} is missing`); this.errors.push(`Missing package.json field: ${field}`); } } // Check runtime dependencies if (runtimeJson.dependencies) { const depCount = Object.keys(runtimeJson.dependencies).length; success(`Runtime dependencies: ${depCount} packages`); // List key dependencies const keyDeps = ['@modelcontextprotocol/sdk', 'express', 'sql.js']; for (const dep of keyDeps) { if (runtimeJson.dependencies[dep]) { success(`Key dependency: ${dep} (${runtimeJson.dependencies[dep]})`); } else { warning(`Key dependency: ${dep} is missing`); this.warnings.push(`Missing key dependency: ${dep}`); } } } else { error('Runtime package has no dependencies'); this.errors.push('Missing runtime dependencies'); } // Check files array if (packageJson.files && Array.isArray(packageJson.files)) { success(`Package files: ${packageJson.files.length} patterns specified`); info(`Files: ${packageJson.files.join(', ')}`); } else { warning('Package files: No files array specified'); this.warnings.push('No files array in package.json'); } } catch (err) { error(`NPM publish prep test failed: ${err.message}`); this.errors.push(`NPM publish prep error: ${err.message}`); } } /** * Test Docker configuration */ testDockerConfig() { section('Testing Docker Configuration'); try { const dockerfiles = ['Dockerfile', 'Dockerfile.railway']; for (const dockerfile of dockerfiles) { const dockerfilePath = path.join(this.rootDir, dockerfile); if (fs.existsSync(dockerfilePath)) { success(`Dockerfile: ${dockerfile} exists`); const content = fs.readFileSync(dockerfilePath, 'utf8'); // Check for key instructions if (content.includes('FROM node:')) { success(`${dockerfile}: Uses Node.js base image`); } else { warning(`${dockerfile}: Does not use standard Node.js base image`); } if (content.includes('COPY dist')) { success(`${dockerfile}: Copies build output`); } else { warning(`${dockerfile}: May not copy build output correctly`); } } else { warning(`Dockerfile: ${dockerfile} not found`); this.warnings.push(`Missing Dockerfile: ${dockerfile}`); } } // Check docker-compose files const composeFiles = ['docker-compose.yml', 'docker-compose.n8n.yml']; for (const composeFile of composeFiles) { const composePath = path.join(this.rootDir, composeFile); if (fs.existsSync(composePath)) { success(`Docker Compose: ${composeFile} exists`); } else { info(`Docker Compose: ${composeFile} not found (optional)`); } } } catch (err) { error(`Docker config test failed: ${err.message}`); this.errors.push(`Docker config error: ${err.message}`); } } /** * Test workflow file syntax */ testWorkflowSyntax() { section('Testing Workflow Syntax'); try { const workflowPath = path.join(this.rootDir, '.github/workflows/release.yml'); if (!fs.existsSync(workflowPath)) { error('Release workflow file not found'); this.errors.push('Missing release workflow file'); return; } const workflowContent = fs.readFileSync(workflowPath, 'utf8'); // Basic YAML structure checks if (workflowContent.includes('name: Automated Release')) { success('Workflow: Has correct name'); } else { warning('Workflow: Name may be incorrect'); } if (workflowContent.includes('on:') && workflowContent.includes('push:')) { success('Workflow: Has push trigger'); } else { error('Workflow: Missing push trigger'); this.errors.push('Workflow missing push trigger'); } if (workflowContent.includes('branches: [main]')) { success('Workflow: Configured for main branch'); } else { warning('Workflow: May not be configured for main branch'); } // Check for required jobs const requiredJobs = [ 'detect-version-change', 'extract-changelog', 'create-release', 'publish-npm', 'build-docker' ]; for (const job of requiredJobs) { if (workflowContent.includes(`${job}:`)) { success(`Workflow job: ${job} defined`); } else { error(`Workflow job: ${job} missing`); this.errors.push(`Missing workflow job: ${job}`); } } // Check for secrets usage if (workflowContent.includes('${{ secrets.NPM_TOKEN }}')) { success('Workflow: NPM_TOKEN secret configured'); } else { warning('Workflow: NPM_TOKEN secret may be missing'); this.warnings.push('NPM_TOKEN secret may need to be configured'); } if (workflowContent.includes('${{ secrets.GITHUB_TOKEN }}')) { success('Workflow: GITHUB_TOKEN secret configured'); } else { warning('Workflow: GITHUB_TOKEN secret may be missing'); } } catch (err) { error(`Workflow syntax test failed: ${err.message}`); this.errors.push(`Workflow syntax error: ${err.message}`); } } /** * Test environment and dependencies */ testEnvironment() { section('Testing Environment'); try { // Check Node.js version const nodeVersion = process.version; success(`Node.js version: ${nodeVersion}`); // Check if npm is available try { const npmVersion = execSync('npm --version', { encoding: 'utf8', stdio: 'pipe' }).trim(); success(`NPM version: ${npmVersion}`); } catch (err) { error('NPM not available'); this.errors.push('NPM not available'); } // Check if git is available try { const gitVersion = execSync('git --version', { encoding: 'utf8', stdio: 'pipe' }).trim(); success(`Git available: ${gitVersion}`); } catch (err) { error('Git not available'); this.errors.push('Git not available'); } // Check if we're in a git repository try { execSync('git rev-parse --git-dir', { stdio: 'pipe' }); success('Git repository: Detected'); // Check current branch try { const branch = execSync('git branch --show-current', { encoding: 'utf8', stdio: 'pipe' }).trim(); info(`Current branch: ${branch}`); } catch (err) { info('Could not determine current branch'); } } catch (err) { warning('Not in a git repository'); this.warnings.push('Not in a git repository'); } } catch (err) { error(`Environment test failed: ${err.message}`); this.errors.push(`Environment error: ${err.message}`); } } /** * Run all tests */ async runAllTests() { header('Release Automation Test Suite'); info('Testing release automation components...'); this.testFileExistence(); this.testVersionDetection(); this.testChangelogParsing(); this.testBuildProcess(); this.testNpmPublishPrep(); this.testDockerConfig(); this.testWorkflowSyntax(); this.testEnvironment(); // Summary header('Test Summary'); if (this.errors.length === 0 && this.warnings.length === 0) { log('🎉 All tests passed! Release automation is ready.', 'green'); } else { if (this.errors.length > 0) { log(`\n❌ ${this.errors.length} Error(s):`, 'red'); this.errors.forEach(err => log(` • ${err}`, 'red')); } if (this.warnings.length > 0) { log(`\n⚠️ ${this.warnings.length} Warning(s):`, 'yellow'); this.warnings.forEach(warn => log(` • ${warn}`, 'yellow')); } if (this.errors.length > 0) { log('\n🔧 Please fix the errors before running the release workflow.', 'red'); process.exit(1); } else { log('\n✅ No critical errors found. Warnings should be reviewed but won\'t prevent releases.', 'yellow'); } } // Next steps log('\n📋 Next Steps:', 'cyan'); log('1. Ensure all secrets are configured in GitHub repository settings:', 'cyan'); log(' • NPM_TOKEN (required for npm publishing)', 'cyan'); log(' • GITHUB_TOKEN (automatically available)', 'cyan'); log('\n2. To trigger a release:', 'cyan'); log(' • Update version in package.json', 'cyan'); log(' • Update changelog in docs/CHANGELOG.md', 'cyan'); log(' • Commit and push to main branch', 'cyan'); log('\n3. Monitor the release workflow in GitHub Actions', 'cyan'); return this.errors.length === 0; } } // Run the tests if (require.main === module) { const tester = new ReleaseAutomationTester(); tester.runAllTests().catch(err => { console.error('Test suite failed:', err); process.exit(1); }); } module.exports = ReleaseAutomationTester; ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/error-handling.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { TestableN8NMCPServer } from './test-helpers'; describe('MCP Error Handling', () => { let mcpServer: TestableN8NMCPServer; let client: Client; beforeEach(async () => { mcpServer = new TestableN8NMCPServer(); await mcpServer.initialize(); const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); await mcpServer.connectToTransport(serverTransport); client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); await client.connect(clientTransport); }); afterEach(async () => { await client.close(); await mcpServer.close(); }); describe('JSON-RPC Error Codes', () => { it('should handle invalid request (parse error)', async () => { // The MCP SDK handles parsing, so we test with invalid method instead try { await (client as any).request({ method: '', // Empty method params: {} }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); } }); it('should handle method not found', async () => { try { await (client as any).request({ method: 'nonexistent/method', params: {} }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); expect(error.message).toContain('not found'); } }); it('should handle invalid params', async () => { try { // Missing required parameter await client.callTool({ name: 'get_node_info', arguments: {} }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); // The error now properly validates required parameters expect(error.message).toContain("Missing required parameters"); } }); it('should handle internal errors gracefully', async () => { try { // Invalid node type format should cause internal processing error await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'completely-invalid-format-$$$$' } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); expect(error.message).toContain('not found'); } }); }); describe('Tool-Specific Errors', () => { describe('Node Discovery Errors', () => { it('should handle invalid category filter', async () => { const response = await client.callTool({ name: 'list_nodes', arguments: { category: 'invalid_category' } }); // Should return empty array, not error const result = JSON.parse((response as any).content[0].text); expect(result).toHaveProperty('nodes'); expect(Array.isArray(result.nodes)).toBe(true); expect(result.nodes).toHaveLength(0); }); it('should handle invalid search mode', async () => { try { await client.callTool({ name: 'search_nodes', arguments: { query: 'test', mode: 'INVALID_MODE' as any } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); } }); it('should handle empty search query', async () => { try { await client.callTool({ name: 'search_nodes', arguments: { query: '' } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); expect(error.message).toContain("search_nodes: Validation failed:"); expect(error.message).toContain("query: query cannot be empty"); } }); it('should handle non-existent node types', async () => { try { await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'nodes-base.thisDoesNotExist' } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); expect(error.message).toContain('not found'); } }); }); describe('Validation Errors', () => { it('should handle invalid validation profile', async () => { try { await client.callTool({ name: 'validate_node_operation', arguments: { nodeType: 'nodes-base.httpRequest', config: { method: 'GET', url: 'https://api.example.com' }, profile: 'invalid_profile' as any } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); } }); it('should handle malformed workflow structure', async () => { try { await client.callTool({ name: 'validate_workflow', arguments: { workflow: { // Missing required 'nodes' array connections: {} } } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); expect(error.message).toContain("validate_workflow: Validation failed:"); expect(error.message).toContain("workflow.nodes: workflow.nodes is required"); } }); it('should handle circular workflow references', async () => { const workflow = { nodes: [ { id: '1', name: 'Node1', type: 'nodes-base.noOp', typeVersion: 1, position: [0, 0], parameters: {} }, { id: '2', name: 'Node2', type: 'nodes-base.noOp', typeVersion: 1, position: [250, 0], parameters: {} } ], connections: { 'Node1': { 'main': [[{ node: 'Node2', type: 'main', index: 0 }]] }, 'Node2': { 'main': [[{ node: 'Node1', type: 'main', index: 0 }]] } } }; const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow } }); const validation = JSON.parse((response as any).content[0].text); expect(validation.warnings).toBeDefined(); }); }); describe('Documentation Errors', () => { it('should handle non-existent documentation topics', async () => { const response = await client.callTool({ name: 'tools_documentation', arguments: { topic: 'completely_fake_tool' } }); expect((response as any).content[0].text).toContain('not found'); }); it('should handle invalid depth parameter', async () => { try { await client.callTool({ name: 'tools_documentation', arguments: { depth: 'invalid_depth' as any } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); } }); }); }); describe('Large Payload Handling', () => { it('should handle large node info requests', async () => { // HTTP Request node has extensive properties const response = await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'nodes-base.httpRequest' } }); expect((response as any).content[0].text.length).toBeGreaterThan(10000); // Should be valid JSON const nodeInfo = JSON.parse((response as any).content[0].text); expect(nodeInfo).toHaveProperty('properties'); }); it('should handle large workflow validation', async () => { // Create a large workflow const nodes = []; const connections: any = {}; for (let i = 0; i < 50; i++) { const nodeName = `Node${i}`; nodes.push({ id: String(i), name: nodeName, type: 'nodes-base.noOp', typeVersion: 1, position: [i * 100, 0], parameters: {} }); if (i > 0) { const prevNode = `Node${i - 1}`; connections[prevNode] = { 'main': [[{ node: nodeName, type: 'main', index: 0 }]] }; } } const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow: { nodes, connections } } }); const validation = JSON.parse((response as any).content[0].text); expect(validation).toHaveProperty('valid'); }); it('should handle many concurrent requests', async () => { const requestCount = 50; const promises = []; for (let i = 0; i < requestCount; i++) { promises.push( client.callTool({ name: 'list_nodes', arguments: { limit: 1, category: i % 2 === 0 ? 'trigger' : 'transform' } }) ); } const responses = await Promise.all(promises); expect(responses).toHaveLength(requestCount); }); }); describe('Invalid JSON Handling', () => { it('should handle invalid JSON in tool parameters', async () => { try { // Config should be an object, not a string await client.callTool({ name: 'validate_node_operation', arguments: { nodeType: 'nodes-base.httpRequest', config: 'invalid json string' as any } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); } }); it('should handle malformed workflow JSON', async () => { try { await client.callTool({ name: 'validate_workflow', arguments: { workflow: 'not a valid workflow object' as any } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); } }); }); describe('Timeout Scenarios', () => { it('should handle rapid sequential requests', async () => { const start = Date.now(); for (let i = 0; i < 20; i++) { await client.callTool({ name: 'get_database_statistics', arguments: {} }); } const duration = Date.now() - start; // Should complete reasonably quickly (under 5 seconds) expect(duration).toBeLessThan(5000); }); it('should handle long-running operations', async () => { // Search with complex query that requires more processing const response = await client.callTool({ name: 'search_nodes', arguments: { 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', mode: 'AND' } }); expect(response).toBeDefined(); }); }); describe('Memory Pressure', () => { it('should handle multiple large responses', async () => { const promises = []; // Request multiple large node infos const largeNodes = [ 'nodes-base.httpRequest', 'nodes-base.postgres', 'nodes-base.googleSheets', 'nodes-base.slack', 'nodes-base.gmail' ]; for (const nodeType of largeNodes) { promises.push( client.callTool({ name: 'get_node_info', arguments: { nodeType } }) .catch(() => null) // Some might not exist ); } const responses = await Promise.all(promises); const validResponses = responses.filter(r => r !== null); expect(validResponses.length).toBeGreaterThan(0); }); it('should handle workflow with many nodes', async () => { const nodeCount = 100; const nodes = []; for (let i = 0; i < nodeCount; i++) { nodes.push({ id: String(i), name: `Node${i}`, type: 'nodes-base.noOp', typeVersion: 1, position: [i * 50, Math.floor(i / 10) * 100], parameters: { // Add some data to increase memory usage data: `This is some test data for node ${i}`.repeat(10) } }); } const response = await client.callTool({ name: 'validate_workflow', arguments: { workflow: { nodes, connections: {} } } }); const validation = JSON.parse((response as any).content[0].text); expect(validation).toHaveProperty('valid'); }); }); describe('Error Recovery', () => { it('should continue working after errors', async () => { // Cause an error try { await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid' } }); } catch (error) { // Expected } // Should still work const response = await client.callTool({ name: 'list_nodes', arguments: { limit: 1 } }); expect(response).toBeDefined(); }); it('should handle mixed success and failure', async () => { const promises = [ client.callTool({ name: 'list_nodes', arguments: { limit: 5 } }), client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid' } }).catch(e => ({ error: e })), client.callTool({ name: 'get_database_statistics', arguments: {} }), client.callTool({ name: 'search_nodes', arguments: { query: '' } }).catch(e => ({ error: e })), client.callTool({ name: 'list_ai_tools', arguments: {} }) ]; const results = await Promise.all(promises); // Some should succeed, some should fail const successes = results.filter(r => !('error' in r)); const failures = results.filter(r => 'error' in r); expect(successes.length).toBeGreaterThan(0); expect(failures.length).toBeGreaterThan(0); }); }); describe('Edge Cases', () => { it('should handle empty responses gracefully', async () => { const response = await client.callTool({ name: 'list_nodes', arguments: { category: 'nonexistent_category' } }); const result = JSON.parse((response as any).content[0].text); expect(result).toHaveProperty('nodes'); expect(Array.isArray(result.nodes)).toBe(true); expect(result.nodes).toHaveLength(0); }); it('should handle special characters in parameters', async () => { const response = await client.callTool({ name: 'search_nodes', arguments: { query: 'test!@#$%^&*()_+-=[]{}|;\':",./<>?' } }); // Should return results or empty array, not error const result = JSON.parse((response as any).content[0].text); expect(result).toHaveProperty('results'); expect(Array.isArray(result.results)).toBe(true); }); it('should handle unicode in parameters', async () => { const response = await client.callTool({ name: 'search_nodes', arguments: { query: 'test 测试 тест परीक्षण' } }); const result = JSON.parse((response as any).content[0].text); expect(result).toHaveProperty('results'); expect(Array.isArray(result.results)).toBe(true); }); it('should handle null and undefined gracefully', async () => { // Most tools should handle missing optional params const response = await client.callTool({ name: 'list_nodes', arguments: { limit: undefined as any, category: null as any } }); const result = JSON.parse((response as any).content[0].text); expect(result).toHaveProperty('nodes'); expect(Array.isArray(result.nodes)).toBe(true); }); }); describe('Error Message Quality', () => { it('should provide helpful error messages', async () => { try { // Use a truly invalid node type await client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid-node-type-that-does-not-exist' } }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error.message).toBeDefined(); expect(error.message.length).toBeGreaterThan(10); // Should mention the issue expect(error.message.toLowerCase()).toMatch(/not found|invalid|missing/); } }); it('should indicate missing required parameters', async () => { try { await client.callTool({ name: 'search_nodes', arguments: {} }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error).toBeDefined(); // The error now properly validates required parameters expect(error.message).toContain("search_nodes: Validation failed:"); expect(error.message).toContain("query: query is required"); } }); it('should provide context for validation errors', async () => { const response = await client.callTool({ name: 'validate_node_operation', arguments: { nodeType: 'nodes-base.httpRequest', config: { // Missing required fields method: 'INVALID_METHOD' } } }); const validation = JSON.parse((response as any).content[0].text); expect(validation.valid).toBe(false); expect(validation.errors).toBeDefined(); expect(Array.isArray(validation.errors)).toBe(true); expect(validation.errors.length).toBeGreaterThan(0); if (validation.errors.length > 0) { expect(validation.errors[0].message).toBeDefined(); // Field property might not exist on all error types if (validation.errors[0].field !== undefined) { expect(validation.errors[0].field).toBeDefined(); } } }); }); }); ``` -------------------------------------------------------------------------------- /src/scripts/fetch-templates.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { createDatabaseAdapter } from '../database/database-adapter'; import { TemplateService } from '../templates/template-service'; import * as fs from 'fs'; import * as path from 'path'; import * as zlib from 'zlib'; import * as dotenv from 'dotenv'; import type { MetadataRequest } from '../templates/metadata-generator'; // Load environment variables dotenv.config(); /** * Extract node configurations from a template workflow */ function extractNodeConfigs( templateId: number, templateName: string, templateViews: number, workflowCompressed: string, metadata: any ): Array<{ node_type: string; template_id: number; template_name: string; template_views: number; node_name: string; parameters_json: string; credentials_json: string | null; has_credentials: number; has_expressions: number; complexity: string; use_cases: string; }> { try { // Decompress workflow const decompressed = zlib.gunzipSync(Buffer.from(workflowCompressed, 'base64')); const workflow = JSON.parse(decompressed.toString('utf-8')); const configs: any[] = []; for (const node of workflow.nodes || []) { // Skip UI-only nodes (sticky notes, etc.) if (node.type.includes('stickyNote') || !node.parameters) { continue; } configs.push({ node_type: node.type, template_id: templateId, template_name: templateName, template_views: templateViews, node_name: node.name, parameters_json: JSON.stringify(node.parameters), credentials_json: node.credentials ? JSON.stringify(node.credentials) : null, has_credentials: node.credentials ? 1 : 0, has_expressions: detectExpressions(node.parameters) ? 1 : 0, complexity: metadata?.complexity || 'medium', use_cases: JSON.stringify(metadata?.use_cases || []) }); } return configs; } catch (error) { console.error(`Error extracting configs from template ${templateId}:`, error); return []; } } /** * Detect n8n expressions in parameters */ function detectExpressions(params: any): boolean { if (!params) return false; const json = JSON.stringify(params); return json.includes('={{') || json.includes('$json') || json.includes('$node'); } /** * Insert extracted configs into database and rank them */ function insertAndRankConfigs(db: any, configs: any[]) { if (configs.length === 0) { console.log('No configs to insert'); return; } // Clear old configs for these templates const templateIds = [...new Set(configs.map(c => c.template_id))]; const placeholders = templateIds.map(() => '?').join(','); db.prepare(`DELETE FROM template_node_configs WHERE template_id IN (${placeholders})`).run(...templateIds); // Insert new configs const insertStmt = db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, credentials_json, has_credentials, has_expressions, complexity, use_cases ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const config of configs) { insertStmt.run( config.node_type, config.template_id, config.template_name, config.template_views, config.node_name, config.parameters_json, config.credentials_json, config.has_credentials, config.has_expressions, config.complexity, config.use_cases ); } // Rank configs per node_type by template popularity db.exec(` UPDATE template_node_configs SET rank = ( SELECT COUNT(*) + 1 FROM template_node_configs AS t2 WHERE t2.node_type = template_node_configs.node_type AND t2.template_views > template_node_configs.template_views ) `); // Keep only top 10 per node_type db.exec(` DELETE FROM template_node_configs WHERE id NOT IN ( SELECT id FROM template_node_configs WHERE rank <= 10 ORDER BY node_type, rank ) `); console.log(`✅ Extracted and ranked ${configs.length} node configurations`); } /** * Extract node configurations from existing templates */ async function extractTemplateConfigs(db: any, service: TemplateService) { console.log('📦 Extracting node configurations from templates...'); const repository = (service as any).repository; const allTemplates = repository.getAllTemplates(); const allConfigs: any[] = []; let configsExtracted = 0; for (const template of allTemplates) { if (template.workflow_json_compressed) { const metadata = template.metadata_json ? JSON.parse(template.metadata_json) : null; const configs = extractNodeConfigs( template.id, template.name, template.views, template.workflow_json_compressed, metadata ); allConfigs.push(...configs); configsExtracted += configs.length; } } if (allConfigs.length > 0) { insertAndRankConfigs(db, allConfigs); // Show stats const configStats = db.prepare(` SELECT COUNT(DISTINCT node_type) as node_types, COUNT(*) as total_configs, AVG(rank) as avg_rank FROM template_node_configs `).get() as any; console.log(`📊 Node config stats:`); console.log(` - Unique node types: ${configStats.node_types}`); console.log(` - Total configs stored: ${configStats.total_configs}`); console.log(` - Average rank: ${configStats.avg_rank?.toFixed(1) || 'N/A'}`); } else { console.log('⚠️ No node configurations extracted'); } } async function fetchTemplates( mode: 'rebuild' | 'update' = 'rebuild', generateMetadata: boolean = false, metadataOnly: boolean = false, extractOnly: boolean = false ) { // If extract-only mode, skip template fetching and only extract configs if (extractOnly) { console.log('📦 Extract-only mode: Extracting node configurations from existing templates...\n'); const db = await createDatabaseAdapter('./data/nodes.db'); // Ensure template_node_configs table exists try { const tableExists = db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name='template_node_configs' `).get(); if (!tableExists) { console.log('📋 Creating template_node_configs table...'); const migrationPath = path.join(__dirname, '../../src/database/migrations/add-template-node-configs.sql'); const migration = fs.readFileSync(migrationPath, 'utf8'); db.exec(migration); console.log('✅ Table created successfully\n'); } } catch (error) { console.error('❌ Error checking/creating template_node_configs table:', error); if ('close' in db && typeof db.close === 'function') { db.close(); } process.exit(1); } const service = new TemplateService(db); await extractTemplateConfigs(db, service); if ('close' in db && typeof db.close === 'function') { db.close(); } return; } // If metadata-only mode, skip template fetching entirely if (metadataOnly) { console.log('🤖 Metadata-only mode: Generating metadata for existing templates...\n'); if (!process.env.OPENAI_API_KEY) { console.error('❌ OPENAI_API_KEY not set in environment'); process.exit(1); } const db = await createDatabaseAdapter('./data/nodes.db'); const service = new TemplateService(db); await generateTemplateMetadata(db, service); if ('close' in db && typeof db.close === 'function') { db.close(); } return; } const modeEmoji = mode === 'rebuild' ? '🔄' : '⬆️'; const modeText = mode === 'rebuild' ? 'Rebuilding' : 'Updating'; console.log(`${modeEmoji} ${modeText} n8n workflow templates...\n`); if (generateMetadata) { console.log('🤖 Metadata generation enabled (using OpenAI)\n'); } // Ensure data directory exists const dataDir = './data'; if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } // Initialize database const db = await createDatabaseAdapter('./data/nodes.db'); // Handle database schema based on mode if (mode === 'rebuild') { try { // Drop existing tables in rebuild mode db.exec('DROP TABLE IF EXISTS templates'); db.exec('DROP TABLE IF EXISTS templates_fts'); console.log('🗑️ Dropped existing templates tables (rebuild mode)\n'); // Apply fresh schema const schema = fs.readFileSync(path.join(__dirname, '../../src/database/schema.sql'), 'utf8'); db.exec(schema); console.log('📋 Applied database schema\n'); } catch (error) { console.error('❌ Error setting up database schema:', error); throw error; } } else { console.log('📊 Update mode: Keeping existing templates and schema\n'); // In update mode, only ensure new columns exist (for migration) try { // Check if metadata columns exist, add them if not (migration support) const columns = db.prepare("PRAGMA table_info(templates)").all() as any[]; const hasMetadataColumn = columns.some((col: any) => col.name === 'metadata_json'); if (!hasMetadataColumn) { console.log('📋 Adding metadata columns to existing schema...'); db.exec(` ALTER TABLE templates ADD COLUMN metadata_json TEXT; ALTER TABLE templates ADD COLUMN metadata_generated_at DATETIME; `); console.log('✅ Metadata columns added\n'); } } catch (error) { // Columns might already exist, that's fine console.log('📋 Schema is up to date\n'); } } // FTS5 initialization is handled by TemplateRepository // No need to duplicate the logic here // Create service const service = new TemplateService(db); // Progress tracking let lastMessage = ''; const startTime = Date.now(); try { await service.fetchAndUpdateTemplates((message, current, total) => { // Clear previous line if (lastMessage) { process.stdout.write('\r' + ' '.repeat(lastMessage.length) + '\r'); } const progress = total > 0 ? Math.round((current / total) * 100) : 0; lastMessage = `📊 ${message}: ${current}/${total} (${progress}%)`; process.stdout.write(lastMessage); }, mode); // Pass the mode parameter! console.log('\n'); // New line after progress // Get stats const stats = await service.getTemplateStats(); const elapsed = Math.round((Date.now() - startTime) / 1000); console.log('✅ Template fetch complete!\n'); console.log('📈 Statistics:'); console.log(` - Total templates: ${stats.totalTemplates}`); console.log(` - Average views: ${stats.averageViews}`); console.log(` - Time elapsed: ${elapsed} seconds`); console.log('\n🔝 Top used nodes:'); stats.topUsedNodes.forEach((node: any, index: number) => { console.log(` ${index + 1}. ${node.node} (${node.count} templates)`); }); // Extract node configurations from templates console.log(''); await extractTemplateConfigs(db, service); // Generate metadata if requested if (generateMetadata && process.env.OPENAI_API_KEY) { console.log('\n🤖 Generating metadata for templates...'); await generateTemplateMetadata(db, service); } else if (generateMetadata && !process.env.OPENAI_API_KEY) { console.log('\n⚠️ Metadata generation requested but OPENAI_API_KEY not set'); } } catch (error) { console.error('\n❌ Error fetching templates:', error); process.exit(1); } // Close database if ('close' in db && typeof db.close === 'function') { db.close(); } } // Generate metadata for templates using OpenAI async function generateTemplateMetadata(db: any, service: TemplateService) { try { const { BatchProcessor } = await import('../templates/batch-processor'); const repository = (service as any).repository; // Get templates without metadata (0 = no limit) const limit = parseInt(process.env.METADATA_LIMIT || '0'); const templatesWithoutMetadata = limit > 0 ? repository.getTemplatesWithoutMetadata(limit) : repository.getTemplatesWithoutMetadata(999999); // Get all if (templatesWithoutMetadata.length === 0) { console.log('✅ All templates already have metadata'); return; } console.log(`Found ${templatesWithoutMetadata.length} templates without metadata`); // Create batch processor const batchSize = parseInt(process.env.OPENAI_BATCH_SIZE || '50'); console.log(`Processing in batches of ${batchSize} templates each`); // Warn if batch size is very large if (batchSize > 100) { console.log(`⚠️ Large batch size (${batchSize}) may take longer to process`); console.log(` Consider using OPENAI_BATCH_SIZE=50 for faster results`); } const processor = new BatchProcessor({ apiKey: process.env.OPENAI_API_KEY!, model: process.env.OPENAI_MODEL || 'gpt-4o-mini', batchSize: batchSize, outputDir: './temp/batch' }); // Prepare metadata requests const requests: MetadataRequest[] = templatesWithoutMetadata.map((t: any) => { let workflow = undefined; try { if (t.workflow_json_compressed) { const decompressed = zlib.gunzipSync(Buffer.from(t.workflow_json_compressed, 'base64')); workflow = JSON.parse(decompressed.toString()); } else if (t.workflow_json) { workflow = JSON.parse(t.workflow_json); } } catch (error) { console.warn(`Failed to parse workflow for template ${t.id}:`, error); } // Parse nodes_used safely let nodes: string[] = []; try { if (t.nodes_used) { nodes = JSON.parse(t.nodes_used); // Ensure it's an array if (!Array.isArray(nodes)) { console.warn(`Template ${t.id} has invalid nodes_used (not an array), using empty array`); nodes = []; } } } catch (error) { console.warn(`Failed to parse nodes_used for template ${t.id}:`, error); nodes = []; } return { templateId: t.id, name: t.name, description: t.description, nodes: nodes, workflow }; }); // Process in batches const results = await processor.processTemplates(requests, (message, current, total) => { process.stdout.write(`\r📊 ${message}: ${current}/${total}`); }); console.log('\n'); // Update database with metadata const metadataMap = new Map(); for (const [templateId, result] of results) { if (!result.error) { metadataMap.set(templateId, result.metadata); } } if (metadataMap.size > 0) { repository.batchUpdateMetadata(metadataMap); console.log(`✅ Updated metadata for ${metadataMap.size} templates`); } // Show stats const stats = repository.getMetadataStats(); console.log('\n📈 Metadata Statistics:'); console.log(` - Total templates: ${stats.total}`); console.log(` - With metadata: ${stats.withMetadata}`); console.log(` - Without metadata: ${stats.withoutMetadata}`); console.log(` - Outdated (>30 days): ${stats.outdated}`); } catch (error) { console.error('\n❌ Error generating metadata:', error); } } // Parse command line arguments function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, metadataOnly: boolean, extractOnly: boolean } { const args = process.argv.slice(2); let mode: 'rebuild' | 'update' = 'rebuild'; let generateMetadata = false; let metadataOnly = false; let extractOnly = false; // Check for --mode flag const modeIndex = args.findIndex(arg => arg.startsWith('--mode')); if (modeIndex !== -1) { const modeArg = args[modeIndex]; const modeValue = modeArg.includes('=') ? modeArg.split('=')[1] : args[modeIndex + 1]; if (modeValue === 'update') { mode = 'update'; } } // Check for --update flag as shorthand if (args.includes('--update')) { mode = 'update'; } // Check for --generate-metadata flag if (args.includes('--generate-metadata') || args.includes('--metadata')) { generateMetadata = true; } // Check for --metadata-only flag if (args.includes('--metadata-only')) { metadataOnly = true; } // Check for --extract-only flag if (args.includes('--extract-only') || args.includes('--extract')) { extractOnly = true; } // Show help if requested if (args.includes('--help') || args.includes('-h')) { console.log('Usage: npm run fetch:templates [options]\n'); console.log('Options:'); console.log(' --mode=rebuild|update Rebuild from scratch or update existing (default: rebuild)'); console.log(' --update Shorthand for --mode=update'); console.log(' --generate-metadata Generate AI metadata after fetching templates'); console.log(' --metadata Shorthand for --generate-metadata'); console.log(' --metadata-only Only generate metadata, skip template fetching'); console.log(' --extract-only Only extract node configs, skip template fetching'); console.log(' --extract Shorthand for --extract-only'); console.log(' --help, -h Show this help message'); process.exit(0); } return { mode, generateMetadata, metadataOnly, extractOnly }; } // Run if called directly if (require.main === module) { const { mode, generateMetadata, metadataOnly, extractOnly } = parseArgs(); fetchTemplates(mode, generateMetadata, metadataOnly, extractOnly).catch(console.error); } export { fetchTemplates }; ``` -------------------------------------------------------------------------------- /tests/unit/database/node-repository-outputs.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { NodeRepository } from '@/database/node-repository'; import { DatabaseAdapter } from '@/database/database-adapter'; import { ParsedNode } from '@/parsers/node-parser'; describe('NodeRepository - Outputs Handling', () => { let repository: NodeRepository; let mockDb: DatabaseAdapter; let mockStatement: any; beforeEach(() => { mockStatement = { run: vi.fn(), get: vi.fn(), all: vi.fn() }; mockDb = { prepare: vi.fn().mockReturnValue(mockStatement), transaction: vi.fn(), exec: vi.fn(), close: vi.fn(), pragma: vi.fn() } as any; repository = new NodeRepository(mockDb); }); describe('saveNode with outputs', () => { it('should save node with outputs and outputNames correctly', () => { const outputs = [ { displayName: 'Done', description: 'Final results when loop completes' }, { displayName: 'Loop', description: 'Current batch data during iteration' } ]; const outputNames = ['done', 'loop']; const node: ParsedNode = { style: 'programmatic', nodeType: 'nodes-base.splitInBatches', displayName: 'Split In Batches', description: 'Split data into batches', category: 'transform', properties: [], credentials: [], isAITool: false, isTrigger: false, isWebhook: false, operations: [], version: '3', isVersioned: false, packageName: 'n8n-nodes-base', outputs, outputNames }; repository.saveNode(node); expect(mockDb.prepare).toHaveBeenCalledWith(` INSERT OR REPLACE INTO nodes ( node_type, package_name, display_name, description, category, development_style, is_ai_tool, is_trigger, is_webhook, is_versioned, version, documentation, properties_schema, operations, credentials_required, outputs, output_names ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); expect(mockStatement.run).toHaveBeenCalledWith( 'nodes-base.splitInBatches', 'n8n-nodes-base', 'Split In Batches', 'Split data into batches', 'transform', 'programmatic', 0, // false 0, // false 0, // false 0, // false '3', null, // documentation JSON.stringify([], null, 2), // properties JSON.stringify([], null, 2), // operations JSON.stringify([], null, 2), // credentials JSON.stringify(outputs, null, 2), // outputs JSON.stringify(outputNames, null, 2) // output_names ); }); it('should save node with only outputs (no outputNames)', () => { const outputs = [ { displayName: 'True', description: 'Items that match condition' }, { displayName: 'False', description: 'Items that do not match condition' } ]; const node: ParsedNode = { style: 'programmatic', nodeType: 'nodes-base.if', displayName: 'IF', description: 'Route items based on conditions', category: 'transform', properties: [], credentials: [], isAITool: false, isTrigger: false, isWebhook: false, operations: [], version: '2', isVersioned: false, packageName: 'n8n-nodes-base', outputs // no outputNames }; repository.saveNode(node); const callArgs = mockStatement.run.mock.calls[0]; expect(callArgs[15]).toBe(JSON.stringify(outputs, null, 2)); // outputs expect(callArgs[16]).toBe(null); // output_names should be null }); it('should save node with only outputNames (no outputs)', () => { const outputNames = ['main', 'error']; const node: ParsedNode = { style: 'programmatic', nodeType: 'nodes-base.customNode', displayName: 'Custom Node', description: 'Custom node with output names only', category: 'transform', properties: [], credentials: [], isAITool: false, isTrigger: false, isWebhook: false, operations: [], version: '1', isVersioned: false, packageName: 'n8n-nodes-base', outputNames // no outputs }; repository.saveNode(node); const callArgs = mockStatement.run.mock.calls[0]; expect(callArgs[15]).toBe(null); // outputs should be null expect(callArgs[16]).toBe(JSON.stringify(outputNames, null, 2)); // output_names }); it('should save node without outputs or outputNames', () => { const node: ParsedNode = { style: 'programmatic', nodeType: 'nodes-base.httpRequest', displayName: 'HTTP Request', description: 'Make HTTP requests', category: 'input', properties: [], credentials: [], isAITool: false, isTrigger: false, isWebhook: false, operations: [], version: '4', isVersioned: false, packageName: 'n8n-nodes-base' // no outputs or outputNames }; repository.saveNode(node); const callArgs = mockStatement.run.mock.calls[0]; expect(callArgs[15]).toBe(null); // outputs should be null expect(callArgs[16]).toBe(null); // output_names should be null }); it('should handle empty outputs and outputNames arrays', () => { const node: ParsedNode = { style: 'programmatic', nodeType: 'nodes-base.emptyNode', displayName: 'Empty Node', description: 'Node with empty outputs', category: 'misc', properties: [], credentials: [], isAITool: false, isTrigger: false, isWebhook: false, operations: [], version: '1', isVersioned: false, packageName: 'n8n-nodes-base', outputs: [], outputNames: [] }; repository.saveNode(node); const callArgs = mockStatement.run.mock.calls[0]; expect(callArgs[15]).toBe(JSON.stringify([], null, 2)); // outputs expect(callArgs[16]).toBe(JSON.stringify([], null, 2)); // output_names }); }); describe('getNode with outputs', () => { it('should retrieve node with outputs and outputNames correctly', () => { const outputs = [ { displayName: 'Done', description: 'Final results when loop completes' }, { displayName: 'Loop', description: 'Current batch data during iteration' } ]; const outputNames = ['done', 'loop']; const mockRow = { node_type: 'nodes-base.splitInBatches', display_name: 'Split In Batches', description: 'Split data into batches', category: 'transform', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '3', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: JSON.stringify(outputs), output_names: JSON.stringify(outputNames) }; mockStatement.get.mockReturnValue(mockRow); const result = repository.getNode('nodes-base.splitInBatches'); expect(result).toEqual({ nodeType: 'nodes-base.splitInBatches', displayName: 'Split In Batches', description: 'Split data into batches', category: 'transform', developmentStyle: 'programmatic', package: 'n8n-nodes-base', isAITool: false, isTrigger: false, isWebhook: false, isVersioned: false, version: '3', properties: [], operations: [], credentials: [], hasDocumentation: false, outputs, outputNames }); }); it('should retrieve node with only outputs (null outputNames)', () => { const outputs = [ { displayName: 'True', description: 'Items that match condition' } ]; const mockRow = { node_type: 'nodes-base.if', display_name: 'IF', description: 'Route items', category: 'transform', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '2', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: JSON.stringify(outputs), output_names: null }; mockStatement.get.mockReturnValue(mockRow); const result = repository.getNode('nodes-base.if'); expect(result.outputs).toEqual(outputs); expect(result.outputNames).toBe(null); }); it('should retrieve node with only outputNames (null outputs)', () => { const outputNames = ['main']; const mockRow = { node_type: 'nodes-base.customNode', display_name: 'Custom Node', description: 'Custom node', category: 'misc', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: null, output_names: JSON.stringify(outputNames) }; mockStatement.get.mockReturnValue(mockRow); const result = repository.getNode('nodes-base.customNode'); expect(result.outputs).toBe(null); expect(result.outputNames).toEqual(outputNames); }); it('should retrieve node without outputs or outputNames', () => { const mockRow = { node_type: 'nodes-base.httpRequest', display_name: 'HTTP Request', description: 'Make HTTP requests', category: 'input', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '4', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: null, output_names: null }; mockStatement.get.mockReturnValue(mockRow); const result = repository.getNode('nodes-base.httpRequest'); expect(result.outputs).toBe(null); expect(result.outputNames).toBe(null); }); it('should handle malformed JSON gracefully', () => { const mockRow = { node_type: 'nodes-base.malformed', display_name: 'Malformed Node', description: 'Node with malformed JSON', category: 'misc', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: '{invalid json}', output_names: '[invalid, json' }; mockStatement.get.mockReturnValue(mockRow); const result = repository.getNode('nodes-base.malformed'); // Should use default values when JSON parsing fails expect(result.outputs).toBe(null); expect(result.outputNames).toBe(null); }); it('should return null for non-existent node', () => { mockStatement.get.mockReturnValue(null); const result = repository.getNode('nodes-base.nonExistent'); expect(result).toBe(null); }); it('should handle SplitInBatches counterintuitive output order correctly', () => { // Test that the output order is preserved: done=0, loop=1 const outputs = [ { displayName: 'Done', description: 'Final results when loop completes', index: 0 }, { displayName: 'Loop', description: 'Current batch data during iteration', index: 1 } ]; const outputNames = ['done', 'loop']; const mockRow = { node_type: 'nodes-base.splitInBatches', display_name: 'Split In Batches', description: 'Split data into batches', category: 'transform', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '3', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: JSON.stringify(outputs), output_names: JSON.stringify(outputNames) }; mockStatement.get.mockReturnValue(mockRow); const result = repository.getNode('nodes-base.splitInBatches'); // Verify order is preserved expect(result.outputs[0].displayName).toBe('Done'); expect(result.outputs[1].displayName).toBe('Loop'); expect(result.outputNames[0]).toBe('done'); expect(result.outputNames[1]).toBe('loop'); }); }); describe('parseNodeRow with outputs', () => { it('should parse node row with outputs correctly using parseNodeRow', () => { const outputs = [{ displayName: 'Output' }]; const outputNames = ['main']; const mockRow = { node_type: 'nodes-base.test', display_name: 'Test', description: 'Test node', category: 'misc', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: JSON.stringify(outputs), output_names: JSON.stringify(outputNames) }; mockStatement.all.mockReturnValue([mockRow]); const results = repository.getAllNodes(1); expect(results[0].outputs).toEqual(outputs); expect(results[0].outputNames).toEqual(outputNames); }); it('should handle empty string as null for outputs', () => { const mockRow = { node_type: 'nodes-base.empty', display_name: 'Empty', description: 'Empty node', category: 'misc', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '1', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: '', // empty string output_names: '' // empty string }; mockStatement.all.mockReturnValue([mockRow]); const results = repository.getAllNodes(1); // Empty strings should be treated as null since they fail JSON parsing expect(results[0].outputs).toBe(null); expect(results[0].outputNames).toBe(null); }); }); describe('complex output structures', () => { it('should handle complex output objects with metadata', () => { const complexOutputs = [ { displayName: 'Done', name: 'done', type: 'main', hint: 'Receives the final data after all batches have been processed', description: 'Final results when loop completes', index: 0 }, { displayName: 'Loop', name: 'loop', type: 'main', hint: 'Receives the current batch data during each iteration', description: 'Current batch data during iteration', index: 1 } ]; const node: ParsedNode = { style: 'programmatic', nodeType: 'nodes-base.splitInBatches', displayName: 'Split In Batches', description: 'Split data into batches', category: 'transform', properties: [], credentials: [], isAITool: false, isTrigger: false, isWebhook: false, operations: [], version: '3', isVersioned: false, packageName: 'n8n-nodes-base', outputs: complexOutputs, outputNames: ['done', 'loop'] }; repository.saveNode(node); // Simulate retrieval const mockRow = { node_type: 'nodes-base.splitInBatches', display_name: 'Split In Batches', description: 'Split data into batches', category: 'transform', development_style: 'programmatic', package_name: 'n8n-nodes-base', is_ai_tool: 0, is_trigger: 0, is_webhook: 0, is_versioned: 0, version: '3', properties_schema: JSON.stringify([]), operations: JSON.stringify([]), credentials_required: JSON.stringify([]), documentation: null, outputs: JSON.stringify(complexOutputs), output_names: JSON.stringify(['done', 'loop']) }; mockStatement.get.mockReturnValue(mockRow); const result = repository.getNode('nodes-base.splitInBatches'); expect(result.outputs).toEqual(complexOutputs); expect(result.outputs[0]).toMatchObject({ displayName: 'Done', name: 'done', type: 'main', hint: 'Receives the final data after all batches have been processed' }); }); }); }); ``` -------------------------------------------------------------------------------- /tests/unit/services/execution-processor.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Execution Processor Service Tests * * Comprehensive test coverage for execution filtering and processing */ import { describe, it, expect } from 'vitest'; import { generatePreview, filterExecutionData, processExecution, } from '../../../src/services/execution-processor'; import { Execution, ExecutionStatus, ExecutionFilterOptions, } from '../../../src/types/n8n-api'; /** * Test data factories */ function createMockExecution(options: { id?: string; status?: ExecutionStatus; nodeData?: Record<string, any>; hasError?: boolean; }): Execution { const { id = 'test-exec-1', status = ExecutionStatus.SUCCESS, nodeData = {}, hasError = false } = options; return { id, workflowId: 'workflow-1', status, mode: 'manual', finished: true, startedAt: '2024-01-01T10:00:00.000Z', stoppedAt: '2024-01-01T10:00:05.000Z', data: { resultData: { runData: nodeData, error: hasError ? { message: 'Test error' } : undefined, }, }, }; } function createNodeData(itemCount: number, includeError = false) { const items = Array.from({ length: itemCount }, (_, i) => ({ json: { id: i + 1, name: `Item ${i + 1}`, value: Math.random() * 100, nested: { field1: `value${i}`, field2: true, }, }, })); return [ { startTime: Date.now(), executionTime: 123, data: { main: [items], }, error: includeError ? { message: 'Node error' } : undefined, }, ]; } /** * Preview Mode Tests */ describe('ExecutionProcessor - Preview Mode', () => { it('should generate preview for empty execution', () => { const execution = createMockExecution({ nodeData: {} }); const { preview, recommendation } = generatePreview(execution); expect(preview.totalNodes).toBe(0); expect(preview.executedNodes).toBe(0); expect(preview.estimatedSizeKB).toBe(0); expect(recommendation.canFetchFull).toBe(true); expect(recommendation.suggestedMode).toBe('full'); // Empty execution is safe to fetch in full }); it('should generate preview with accurate item counts', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), 'Filter': createNodeData(12), }, }); const { preview } = generatePreview(execution); expect(preview.totalNodes).toBe(2); expect(preview.executedNodes).toBe(2); expect(preview.nodes['HTTP Request'].itemCounts.output).toBe(50); expect(preview.nodes['Filter'].itemCounts.output).toBe(12); }); it('should extract data structure from nodes', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(5), }, }); const { preview } = generatePreview(execution); const structure = preview.nodes['HTTP Request'].dataStructure; expect(structure).toHaveProperty('json'); expect(structure.json).toHaveProperty('id'); expect(structure.json).toHaveProperty('name'); expect(structure.json).toHaveProperty('nested'); expect(structure.json.id).toBe('number'); expect(structure.json.name).toBe('string'); expect(typeof structure.json.nested).toBe('object'); }); it('should estimate data size', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const { preview } = generatePreview(execution); expect(preview.estimatedSizeKB).toBeGreaterThan(0); expect(preview.nodes['HTTP Request'].estimatedSizeKB).toBeGreaterThan(0); }); it('should detect error status in nodes', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(5, true), }, }); const { preview } = generatePreview(execution); expect(preview.nodes['HTTP Request'].status).toBe('error'); expect(preview.nodes['HTTP Request'].error).toBeDefined(); }); it('should recommend full mode for small datasets', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(5), }, }); const { recommendation } = generatePreview(execution); expect(recommendation.canFetchFull).toBe(true); expect(recommendation.suggestedMode).toBe('full'); }); it('should recommend filtered mode for large datasets', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(100), }, }); const { recommendation } = generatePreview(execution); expect(recommendation.canFetchFull).toBe(false); expect(recommendation.suggestedMode).toBe('filtered'); expect(recommendation.suggestedItemsLimit).toBeGreaterThan(0); }); it('should recommend summary mode for moderate datasets', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(30), }, }); const { recommendation } = generatePreview(execution); expect(recommendation.canFetchFull).toBe(false); expect(recommendation.suggestedMode).toBe('summary'); }); }); /** * Filtering Mode Tests */ describe('ExecutionProcessor - Filtering', () => { it('should filter by node names', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(10), 'Filter': createNodeData(5), 'Set': createNodeData(3), }, }); const options: ExecutionFilterOptions = { mode: 'filtered', nodeNames: ['HTTP Request', 'Filter'], }; const result = filterExecutionData(execution, options); expect(result.nodes).toHaveProperty('HTTP Request'); expect(result.nodes).toHaveProperty('Filter'); expect(result.nodes).not.toHaveProperty('Set'); expect(result.summary?.executedNodes).toBe(2); }); it('should handle non-existent node names gracefully', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(10), }, }); const options: ExecutionFilterOptions = { mode: 'filtered', nodeNames: ['NonExistent'], }; const result = filterExecutionData(execution, options); expect(Object.keys(result.nodes || {})).toHaveLength(0); expect(result.summary?.executedNodes).toBe(0); }); it('should limit items to 0 (structure only)', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const options: ExecutionFilterOptions = { mode: 'filtered', itemsLimit: 0, }; const result = filterExecutionData(execution, options); const nodeData = result.nodes?.['HTTP Request']; expect(nodeData?.data?.metadata.itemsShown).toBe(0); expect(nodeData?.data?.metadata.truncated).toBe(true); expect(nodeData?.data?.metadata.totalItems).toBe(50); // Check that we have structure but no actual values const output = nodeData?.data?.output?.[0]?.[0]; expect(output).toBeDefined(); expect(typeof output).toBe('object'); }); it('should limit items to 2 (default)', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const options: ExecutionFilterOptions = { mode: 'summary', }; const result = filterExecutionData(execution, options); const nodeData = result.nodes?.['HTTP Request']; expect(nodeData?.data?.metadata.itemsShown).toBe(2); expect(nodeData?.data?.metadata.totalItems).toBe(50); expect(nodeData?.data?.metadata.truncated).toBe(true); expect(nodeData?.data?.output?.[0]).toHaveLength(2); }); it('should limit items to custom value', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const options: ExecutionFilterOptions = { mode: 'filtered', itemsLimit: 5, }; const result = filterExecutionData(execution, options); const nodeData = result.nodes?.['HTTP Request']; expect(nodeData?.data?.metadata.itemsShown).toBe(5); expect(nodeData?.data?.metadata.truncated).toBe(true); expect(nodeData?.data?.output?.[0]).toHaveLength(5); }); it('should not truncate when itemsLimit is -1 (unlimited)', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const options: ExecutionFilterOptions = { mode: 'filtered', itemsLimit: -1, }; const result = filterExecutionData(execution, options); const nodeData = result.nodes?.['HTTP Request']; expect(nodeData?.data?.metadata.itemsShown).toBe(50); expect(nodeData?.data?.metadata.totalItems).toBe(50); expect(nodeData?.data?.metadata.truncated).toBe(false); }); it('should not truncate when items are less than limit', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(3), }, }); const options: ExecutionFilterOptions = { mode: 'filtered', itemsLimit: 5, }; const result = filterExecutionData(execution, options); const nodeData = result.nodes?.['HTTP Request']; expect(nodeData?.data?.metadata.itemsShown).toBe(3); expect(nodeData?.data?.metadata.truncated).toBe(false); }); it('should include input data when requested', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': [ { startTime: Date.now(), executionTime: 100, inputData: [[{ json: { input: 'test' } }]], data: { main: [[{ json: { output: 'result' } }]], }, }, ], }, }); const options: ExecutionFilterOptions = { mode: 'filtered', includeInputData: true, }; const result = filterExecutionData(execution, options); const nodeData = result.nodes?.['HTTP Request']; expect(nodeData?.data?.input).toBeDefined(); expect(nodeData?.data?.input?.[0]?.[0]?.json?.input).toBe('test'); }); it('should not include input data by default', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': [ { startTime: Date.now(), executionTime: 100, inputData: [[{ json: { input: 'test' } }]], data: { main: [[{ json: { output: 'result' } }]], }, }, ], }, }); const options: ExecutionFilterOptions = { mode: 'filtered', }; const result = filterExecutionData(execution, options); const nodeData = result.nodes?.['HTTP Request']; expect(nodeData?.data?.input).toBeUndefined(); }); }); /** * Mode Tests */ describe('ExecutionProcessor - Modes', () => { it('should handle preview mode', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const result = filterExecutionData(execution, { mode: 'preview' }); expect(result.mode).toBe('preview'); expect(result.preview).toBeDefined(); expect(result.recommendation).toBeDefined(); expect(result.nodes).toBeUndefined(); }); it('should handle summary mode', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const result = filterExecutionData(execution, { mode: 'summary' }); expect(result.mode).toBe('summary'); expect(result.summary).toBeDefined(); expect(result.nodes).toBeDefined(); expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(2); }); it('should handle filtered mode', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const result = filterExecutionData(execution, { mode: 'filtered', itemsLimit: 5, }); expect(result.mode).toBe('filtered'); expect(result.summary).toBeDefined(); expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(5); }); it('should handle full mode', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const result = filterExecutionData(execution, { mode: 'full' }); expect(result.mode).toBe('full'); expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(50); expect(result.nodes?.['HTTP Request']?.data?.metadata.truncated).toBe(false); }); }); /** * Edge Cases */ describe('ExecutionProcessor - Edge Cases', () => { it('should handle execution with no data', () => { const execution: Execution = { id: 'test-1', workflowId: 'workflow-1', status: ExecutionStatus.SUCCESS, mode: 'manual', finished: true, startedAt: '2024-01-01T10:00:00.000Z', stoppedAt: '2024-01-01T10:00:05.000Z', }; const result = filterExecutionData(execution, { mode: 'summary' }); expect(result.summary?.totalNodes).toBe(0); expect(result.summary?.executedNodes).toBe(0); }); it('should handle execution with error', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(5), }, hasError: true, }); const result = filterExecutionData(execution, { mode: 'summary' }); expect(result.error).toBeDefined(); }); it('should handle empty node data arrays', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': [], }, }); const result = filterExecutionData(execution, { mode: 'summary' }); expect(result.nodes?.['HTTP Request']).toBeDefined(); expect(result.nodes?.['HTTP Request'].itemsOutput).toBe(0); }); it('should handle nested data structures', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': [ { startTime: Date.now(), executionTime: 100, data: { main: [[{ json: { deeply: { nested: { structure: { value: 'test', array: [1, 2, 3], }, }, }, }, }]], }, }, ], }, }); const { preview } = generatePreview(execution); const structure = preview.nodes['HTTP Request'].dataStructure; expect(structure.json.deeply).toBeDefined(); expect(typeof structure.json.deeply).toBe('object'); }); it('should calculate duration correctly', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(5), }, }); const result = filterExecutionData(execution, { mode: 'summary' }); expect(result.duration).toBe(5000); // 5 seconds }); it('should handle execution without stop time', () => { const execution: Execution = { id: 'test-1', workflowId: 'workflow-1', status: ExecutionStatus.WAITING, mode: 'manual', finished: false, startedAt: '2024-01-01T10:00:00.000Z', data: { resultData: { runData: {}, }, }, }; const result = filterExecutionData(execution, { mode: 'summary' }); expect(result.duration).toBeUndefined(); expect(result.finished).toBe(false); }); }); /** * processExecution Tests */ describe('ExecutionProcessor - processExecution', () => { it('should return original execution when no options provided', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(5), }, }); const result = processExecution(execution, {}); expect(result).toBe(execution); }); it('should process when mode is specified', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(5), }, }); const result = processExecution(execution, { mode: 'preview' }); expect(result).not.toBe(execution); expect((result as any).mode).toBe('preview'); }); it('should process when filtering options are provided', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(5), 'Filter': createNodeData(3), }, }); const result = processExecution(execution, { nodeNames: ['HTTP Request'] }); expect(result).not.toBe(execution); expect((result as any).nodes).toHaveProperty('HTTP Request'); expect((result as any).nodes).not.toHaveProperty('Filter'); }); }); /** * Summary Statistics Tests */ describe('ExecutionProcessor - Summary Statistics', () => { it('should calculate hasMoreData correctly', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(50), }, }); const result = filterExecutionData(execution, { mode: 'summary', itemsLimit: 2, }); expect(result.summary?.hasMoreData).toBe(true); }); it('should set hasMoreData to false when all data is included', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(2), }, }); const result = filterExecutionData(execution, { mode: 'summary', itemsLimit: 5, }); expect(result.summary?.hasMoreData).toBe(false); }); it('should count total items correctly across multiple nodes', () => { const execution = createMockExecution({ nodeData: { 'HTTP Request': createNodeData(10), 'Filter': createNodeData(5), 'Set': createNodeData(3), }, }); const result = filterExecutionData(execution, { mode: 'summary' }); expect(result.summary?.totalItems).toBe(18); }); }); ``` -------------------------------------------------------------------------------- /tests/integration/database/template-node-configs.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { DatabaseAdapter, createDatabaseAdapter } from '../../../src/database/database-adapter'; import fs from 'fs'; import path from 'path'; /** * Integration tests for template_node_configs table * Testing database schema, migrations, and data operations */ describe('Template Node Configs Database Integration', () => { let db: DatabaseAdapter; let dbPath: string; beforeEach(async () => { // Create temporary database dbPath = ':memory:'; db = await createDatabaseAdapter(dbPath); // Apply schema const schemaPath = path.join(__dirname, '../../../src/database/schema.sql'); const schema = fs.readFileSync(schemaPath, 'utf-8'); db.exec(schema); // Apply migration const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql'); const migration = fs.readFileSync(migrationPath, 'utf-8'); db.exec(migration); // Insert test templates with id 1-1000 to satisfy foreign key constraints // Tests insert configs with various template_id values, so we pre-create many templates const stmt = db.prepare(` INSERT INTO templates ( id, workflow_id, name, description, views, nodes_used, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) `); for (let i = 1; i <= 1000; i++) { stmt.run(i, i, `Test Template ${i}`, 'Test template for node configs', 100, '[]'); } }); afterEach(() => { if ('close' in db && typeof db.close === 'function') { db.close(); } }); describe('Schema Validation', () => { it('should create template_node_configs table', () => { const tableExists = db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name='template_node_configs' `).get(); expect(tableExists).toBeDefined(); expect(tableExists).toHaveProperty('name', 'template_node_configs'); }); it('should have all required columns', () => { const columns = db.prepare(`PRAGMA table_info(template_node_configs)`).all() as any[]; const columnNames = columns.map(col => col.name); expect(columnNames).toContain('id'); expect(columnNames).toContain('node_type'); expect(columnNames).toContain('template_id'); expect(columnNames).toContain('template_name'); expect(columnNames).toContain('template_views'); expect(columnNames).toContain('node_name'); expect(columnNames).toContain('parameters_json'); expect(columnNames).toContain('credentials_json'); expect(columnNames).toContain('has_credentials'); expect(columnNames).toContain('has_expressions'); expect(columnNames).toContain('complexity'); expect(columnNames).toContain('use_cases'); expect(columnNames).toContain('rank'); expect(columnNames).toContain('created_at'); }); it('should have correct column types and constraints', () => { const columns = db.prepare(`PRAGMA table_info(template_node_configs)`).all() as any[]; const idColumn = columns.find(col => col.name === 'id'); expect(idColumn.pk).toBe(1); // Primary key const nodeTypeColumn = columns.find(col => col.name === 'node_type'); expect(nodeTypeColumn.notnull).toBe(1); // NOT NULL const parametersJsonColumn = columns.find(col => col.name === 'parameters_json'); expect(parametersJsonColumn.notnull).toBe(1); // NOT NULL }); it('should have complexity CHECK constraint', () => { // Try to insert invalid complexity expect(() => { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, complexity ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', 1, 'Test Template', 100, 'Test Node', '{}', 'invalid' // Should fail CHECK constraint ); }).toThrow(); }); it('should accept valid complexity values', () => { const validComplexities = ['simple', 'medium', 'complex']; validComplexities.forEach((complexity, index) => { expect(() => { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, complexity ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', index + 1, 'Test Template', 100, 'Test Node', '{}', complexity ); }).not.toThrow(); }); const count = db.prepare('SELECT COUNT(*) as count FROM template_node_configs').get() as any; expect(count.count).toBe(3); }); }); describe('Indexes', () => { it('should create idx_config_node_type_rank index', () => { const indexes = db.prepare(` SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='template_node_configs' `).all() as any[]; const indexNames = indexes.map(idx => idx.name); expect(indexNames).toContain('idx_config_node_type_rank'); }); it('should create idx_config_complexity index', () => { const indexes = db.prepare(` SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='template_node_configs' `).all() as any[]; const indexNames = indexes.map(idx => idx.name); expect(indexNames).toContain('idx_config_complexity'); }); it('should create idx_config_auth index', () => { const indexes = db.prepare(` SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='template_node_configs' `).all() as any[]; const indexNames = indexes.map(idx => idx.name); expect(indexNames).toContain('idx_config_auth'); }); }); describe('View: ranked_node_configs', () => { it('should create ranked_node_configs view', () => { const viewExists = db.prepare(` SELECT name FROM sqlite_master WHERE type='view' AND name='ranked_node_configs' `).get(); expect(viewExists).toBeDefined(); expect(viewExists).toHaveProperty('name', 'ranked_node_configs'); }); it('should return only top 5 ranked configs per node type', () => { // Insert 10 configs for same node type with different ranks for (let i = 1; i <= 10; i++) { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.httpRequest', i, `Template ${i}`, 1000 - (i * 50), // Decreasing views 'HTTP Request', '{}', i // Rank 1-10 ); } const rankedConfigs = db.prepare('SELECT * FROM ranked_node_configs').all() as any[]; // Should only return rank 1-5 expect(rankedConfigs).toHaveLength(5); expect(Math.max(...rankedConfigs.map(c => c.rank))).toBe(5); expect(Math.min(...rankedConfigs.map(c => c.rank))).toBe(1); }); it('should order by node_type and rank', () => { // Insert configs for multiple node types const configs = [ { nodeType: 'n8n-nodes-base.webhook', rank: 2 }, { nodeType: 'n8n-nodes-base.webhook', rank: 1 }, { nodeType: 'n8n-nodes-base.httpRequest', rank: 2 }, { nodeType: 'n8n-nodes-base.httpRequest', rank: 1 }, ]; configs.forEach((config, index) => { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( config.nodeType, index + 1, `Template ${index}`, 100, 'Node', '{}', config.rank ); }); const rankedConfigs = db.prepare('SELECT * FROM ranked_node_configs ORDER BY node_type, rank').all() as any[]; // First two should be httpRequest rank 1, 2 expect(rankedConfigs[0].node_type).toBe('n8n-nodes-base.httpRequest'); expect(rankedConfigs[0].rank).toBe(1); expect(rankedConfigs[1].node_type).toBe('n8n-nodes-base.httpRequest'); expect(rankedConfigs[1].rank).toBe(2); // Last two should be webhook rank 1, 2 expect(rankedConfigs[2].node_type).toBe('n8n-nodes-base.webhook'); expect(rankedConfigs[2].rank).toBe(1); expect(rankedConfigs[3].node_type).toBe('n8n-nodes-base.webhook'); expect(rankedConfigs[3].rank).toBe(2); }); }); describe('Foreign Key Constraints', () => { beforeEach(() => { // Enable foreign keys db.exec('PRAGMA foreign_keys = ON'); // Note: Templates are already created in the main beforeEach }); it('should allow inserting config with valid template_id', () => { expect(() => { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json ) VALUES (?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', 1, // Valid template_id 'Test Template', 100, 'Test Node', '{}' ); }).not.toThrow(); }); it('should cascade delete configs when template is deleted', () => { // Insert config db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json ) VALUES (?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', 1, 'Test Template', 100, 'Test Node', '{}' ); // Verify config exists let configs = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').all(1) as any[]; expect(configs).toHaveLength(1); // Delete template db.prepare('DELETE FROM templates WHERE id = ?').run(1); // Verify config is deleted (CASCADE) configs = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').all(1) as any[]; expect(configs).toHaveLength(0); }); }); describe('Data Operations', () => { it('should insert and retrieve config with all fields', () => { const testConfig = { node_type: 'n8n-nodes-base.webhook', template_id: 1, template_name: 'Webhook Template', template_views: 2000, node_name: 'Webhook Trigger', parameters_json: JSON.stringify({ httpMethod: 'POST', path: 'webhook-test', responseMode: 'lastNode' }), credentials_json: JSON.stringify({ webhookAuth: { id: '1', name: 'Webhook Auth' } }), has_credentials: 1, has_expressions: 1, complexity: 'medium', use_cases: JSON.stringify(['webhook processing', 'automation triggers']), rank: 1 }; db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, credentials_json, has_credentials, has_expressions, complexity, use_cases, rank ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(...Object.values(testConfig)); const retrieved = db.prepare('SELECT * FROM template_node_configs WHERE id = 1').get() as any; expect(retrieved.node_type).toBe(testConfig.node_type); expect(retrieved.template_id).toBe(testConfig.template_id); expect(retrieved.template_name).toBe(testConfig.template_name); expect(retrieved.template_views).toBe(testConfig.template_views); expect(retrieved.node_name).toBe(testConfig.node_name); expect(retrieved.parameters_json).toBe(testConfig.parameters_json); expect(retrieved.credentials_json).toBe(testConfig.credentials_json); expect(retrieved.has_credentials).toBe(testConfig.has_credentials); expect(retrieved.has_expressions).toBe(testConfig.has_expressions); expect(retrieved.complexity).toBe(testConfig.complexity); expect(retrieved.use_cases).toBe(testConfig.use_cases); expect(retrieved.rank).toBe(testConfig.rank); expect(retrieved.created_at).toBeDefined(); }); it('should handle nullable fields correctly', () => { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json ) VALUES (?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', 1, 'Test', 100, 'Node', '{}' ); const retrieved = db.prepare('SELECT * FROM template_node_configs WHERE id = 1').get() as any; expect(retrieved.credentials_json).toBeNull(); expect(retrieved.has_credentials).toBe(0); // Default value expect(retrieved.has_expressions).toBe(0); // Default value expect(retrieved.rank).toBe(0); // Default value }); it('should update rank values', () => { // Insert multiple configs for (let i = 1; i <= 3; i++) { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', i, 'Template', 100, 'Node', '{}', 0 // Initial rank ); } // Update ranks db.exec(` UPDATE template_node_configs SET rank = ( SELECT COUNT(*) + 1 FROM template_node_configs AS t2 WHERE t2.node_type = template_node_configs.node_type AND t2.template_views > template_node_configs.template_views ) `); const configs = db.prepare('SELECT * FROM template_node_configs ORDER BY rank').all() as any[]; // All should have same rank (same views) expect(configs.every(c => c.rank === 1)).toBe(true); }); it('should delete configs with rank > 10', () => { // Insert 15 configs with different ranks for (let i = 1; i <= 15; i++) { db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'n8n-nodes-base.test', i, 'Template', 100, 'Node', '{}', i // Rank 1-15 ); } // Delete configs with rank > 10 db.exec(` DELETE FROM template_node_configs WHERE id NOT IN ( SELECT id FROM template_node_configs WHERE rank <= 10 ORDER BY node_type, rank ) `); const remaining = db.prepare('SELECT * FROM template_node_configs').all() as any[]; expect(remaining).toHaveLength(10); expect(Math.max(...remaining.map(c => c.rank))).toBe(10); }); }); describe('Query Performance', () => { beforeEach(() => { // Insert 1000 configs for performance testing const stmt = db.prepare(` INSERT INTO template_node_configs ( node_type, template_id, template_name, template_views, node_name, parameters_json, rank ) VALUES (?, ?, ?, ?, ?, ?, ?) `); const nodeTypes = [ 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.webhook', 'n8n-nodes-base.slack', 'n8n-nodes-base.googleSheets', 'n8n-nodes-base.code' ]; for (let i = 1; i <= 1000; i++) { const nodeType = nodeTypes[i % nodeTypes.length]; stmt.run( nodeType, i, `Template ${i}`, Math.floor(Math.random() * 10000), 'Node', '{}', (i % 10) + 1 // Rank 1-10 ); } }); it('should query by node_type and rank efficiently', () => { const start = Date.now(); const results = db.prepare(` SELECT * FROM template_node_configs WHERE node_type = ? ORDER BY rank LIMIT 3 `).all('n8n-nodes-base.httpRequest') as any[]; const duration = Date.now() - start; expect(results.length).toBeGreaterThan(0); expect(duration).toBeLessThan(10); // Should be very fast with index }); it('should filter by complexity efficiently', () => { // First set some complexity values db.exec(`UPDATE template_node_configs SET complexity = 'simple' WHERE id % 3 = 0`); db.exec(`UPDATE template_node_configs SET complexity = 'medium' WHERE id % 3 = 1`); db.exec(`UPDATE template_node_configs SET complexity = 'complex' WHERE id % 3 = 2`); const start = Date.now(); const results = db.prepare(` SELECT * FROM template_node_configs WHERE node_type = ? AND complexity = ? ORDER BY rank LIMIT 5 `).all('n8n-nodes-base.webhook', 'simple') as any[]; const duration = Date.now() - start; expect(duration).toBeLessThan(10); // Should be fast with index }); }); describe('Migration Idempotency', () => { it('should be safe to run migration multiple times', () => { const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql'); const migration = fs.readFileSync(migrationPath, 'utf-8'); // Run migration again expect(() => { db.exec(migration); }).not.toThrow(); // Table should still exist const tableExists = db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name='template_node_configs' `).get(); expect(tableExists).toBeDefined(); }); }); }); ```