diff --git a/data/nodes.db b/data/nodes.db index 37b0fdb..1142059 100644 Binary files a/data/nodes.db and b/data/nodes.db differ diff --git a/package-lock.json b/package-lock.json index 534f0dd..8313296 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", + "axios-mock-adapter": "^2.1.0", "fishery": "^2.3.1", "msw": "^2.10.4", "nodemon": "^3.1.10", @@ -15101,6 +15102,44 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, + "node_modules/axios-mock-adapter/node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/axios-retry": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", diff --git a/package.json b/package.json index f47916f..660a5b3 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", + "axios-mock-adapter": "^2.1.0", "fishery": "^2.3.1", "msw": "^2.10.4", "nodemon": "^3.1.10", diff --git a/tests/integration/test-ai-agent-extraction.ts b/tests/integration/test-ai-agent-extraction.ts deleted file mode 100644 index 5dd1b8d..0000000 --- a/tests/integration/test-ai-agent-extraction.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { spawn } from 'child_process'; -import * as path from 'path'; - -/** - * Integration test for AI Agent node extraction - * This simulates an MCP client requesting the AI Agent code from n8n - */ -async function testAIAgentExtraction() { - console.log('=== AI Agent Node Extraction Test ===\n'); - - // Create MCP client - const client = new Client( - { - name: 'test-mcp-client', - version: '1.0.0', - }, - { - capabilities: {}, - } - ); - - try { - console.log('1. Starting MCP server...'); - const serverPath = path.join(__dirname, '../../dist/index.js'); - const transport = new StdioClientTransport({ - command: 'node', - args: [serverPath], - env: { - ...process.env, - N8N_API_URL: process.env.N8N_API_URL || 'http://localhost:5678', - N8N_API_KEY: process.env.N8N_API_KEY || 'test-key', - LOG_LEVEL: 'debug', - }, - }); - - await client.connect(transport); - console.log('✓ Connected to MCP server\n'); - - // Test 1: List available tools - console.log('2. Listing available tools...'); - const toolsResponse = await client.request( - { method: 'tools/list' }, - {} as any - ) as any; - console.log(`✓ Found ${toolsResponse.tools.length} tools`); - - const hasNodeSourceTool = toolsResponse.tools.some( - (tool: any) => tool.name === 'get_node_source_code' - ); - console.log(`✓ Node source extraction tool available: ${hasNodeSourceTool}\n`); - - // Test 2: List available nodes - console.log('3. Listing available nodes...'); - const listNodesResponse = await client.request( - { - method: 'tools/call', - params: { - name: 'list_available_nodes', - arguments: { - search: 'agent', - }, - }, - }, - {} as any - ) as any; - console.log(`✓ Found nodes matching 'agent':`); - const content = JSON.parse(listNodesResponse.content[0].text); - content.nodes.forEach((node: any) => { - console.log(` - ${node.displayName}: ${node.description}`); - }); - console.log(); - - // Test 3: Extract AI Agent node source code - console.log('4. Extracting AI Agent node source code...'); - const aiAgentResponse = await client.request( - { - method: 'tools/call', - params: { - name: 'get_node_source_code', - arguments: { - nodeType: '@n8n/n8n-nodes-langchain.Agent', - includeCredentials: true, - }, - }, - }, - {} as any - ) as any; - - const result = JSON.parse(aiAgentResponse.content[0].text); - console.log('✓ Successfully extracted AI Agent node:'); - console.log(` - Node Type: ${result.nodeType}`); - console.log(` - Location: ${result.location}`); - console.log(` - Source Code Length: ${result.sourceCode.length} characters`); - console.log(` - Has Credential Code: ${!!result.credentialCode}`); - - if (result.packageInfo) { - console.log(` - Package: ${result.packageInfo.name} v${result.packageInfo.version}`); - } - - // Show a snippet of the code - console.log('\n5. Source Code Preview:'); - console.log('```javascript'); - console.log(result.sourceCode.substring(0, 500) + '...'); - console.log('```\n'); - - // Test 4: Use resource endpoint - console.log('6. Testing resource endpoint...'); - const resourceResponse = await client.request( - { - method: 'resources/read', - params: { - uri: 'nodes://source/@n8n/n8n-nodes-langchain.Agent', - }, - }, - {} as any - ) as any; - console.log('✓ Successfully read node source via resource endpoint\n'); - - console.log('=== Test Completed Successfully ==='); - - await client.close(); - process.exit(0); - } catch (error) { - console.error('Test failed:', error); - await client.close(); - process.exit(1); - } -} - -// Run the test -testAIAgentExtraction().catch(console.error); \ No newline at end of file diff --git a/tests/utils/assertions.ts b/tests/utils/assertions.ts new file mode 100644 index 0000000..2c13788 --- /dev/null +++ b/tests/utils/assertions.ts @@ -0,0 +1,280 @@ +import { expect } from 'vitest'; +import { INodeDefinition, IWorkflow, INode } from '@/types/n8n-api'; + +/** + * Custom assertions for n8n-mcp tests + */ + +/** + * Assert that a value is a valid node definition + */ +export function expectValidNodeDefinition(node: any): asserts node is INodeDefinition { + expect(node).toBeDefined(); + expect(node).toHaveProperty('name'); + expect(node).toHaveProperty('displayName'); + expect(node).toHaveProperty('version'); + expect(node).toHaveProperty('properties'); + expect(node.properties).toBeInstanceOf(Array); + + // Check version is a positive number + expect(node.version).toBeGreaterThan(0); + + // Check required string fields + expect(typeof node.name).toBe('string'); + expect(typeof node.displayName).toBe('string'); + expect(node.name).not.toBe(''); + expect(node.displayName).not.toBe(''); +} + +/** + * Assert that a value is a valid workflow + */ +export function expectValidWorkflow(workflow: any): asserts workflow is IWorkflow { + expect(workflow).toBeDefined(); + expect(workflow).toHaveProperty('nodes'); + expect(workflow).toHaveProperty('connections'); + expect(workflow.nodes).toBeInstanceOf(Array); + expect(workflow.connections).toBeTypeOf('object'); + + // Check each node is valid + workflow.nodes.forEach((node: any) => { + expectValidWorkflowNode(node); + }); + + // Check connections reference valid nodes + const nodeIds = new Set(workflow.nodes.map((n: INode) => n.id)); + Object.keys(workflow.connections).forEach(sourceId => { + expect(nodeIds.has(sourceId)).toBe(true); + + const connections = workflow.connections[sourceId]; + Object.values(connections).forEach((outputConnections: any) => { + outputConnections.forEach((connectionSet: any) => { + connectionSet.forEach((connection: any) => { + expect(nodeIds.has(connection.node)).toBe(true); + }); + }); + }); + }); +} + +/** + * Assert that a value is a valid workflow node + */ +export function expectValidWorkflowNode(node: any): asserts node is INode { + expect(node).toBeDefined(); + expect(node).toHaveProperty('id'); + expect(node).toHaveProperty('name'); + expect(node).toHaveProperty('type'); + expect(node).toHaveProperty('typeVersion'); + expect(node).toHaveProperty('position'); + expect(node).toHaveProperty('parameters'); + + // Check types + expect(typeof node.id).toBe('string'); + expect(typeof node.name).toBe('string'); + expect(typeof node.type).toBe('string'); + expect(typeof node.typeVersion).toBe('number'); + expect(node.position).toBeInstanceOf(Array); + expect(node.position).toHaveLength(2); + expect(typeof node.position[0]).toBe('number'); + expect(typeof node.position[1]).toBe('number'); + expect(node.parameters).toBeTypeOf('object'); +} + +/** + * Assert that validation errors contain expected messages + */ +export function expectValidationErrors(errors: any[], expectedMessages: string[]) { + expect(errors).toHaveLength(expectedMessages.length); + + const errorMessages = errors.map(e => + typeof e === 'string' ? e : e.message || e.error || String(e) + ); + + expectedMessages.forEach(expected => { + const found = errorMessages.some(msg => + msg.toLowerCase().includes(expected.toLowerCase()) + ); + expect(found).toBe(true); + }); +} + +/** + * Assert that a property definition is valid + */ +export function expectValidPropertyDefinition(property: any) { + expect(property).toBeDefined(); + expect(property).toHaveProperty('name'); + expect(property).toHaveProperty('displayName'); + expect(property).toHaveProperty('type'); + + // Check required fields + expect(typeof property.name).toBe('string'); + expect(typeof property.displayName).toBe('string'); + expect(typeof property.type).toBe('string'); + + // Check common property types + const validTypes = [ + 'string', 'number', 'boolean', 'options', 'multiOptions', + 'collection', 'fixedCollection', 'json', 'color', 'dateTime' + ]; + expect(validTypes).toContain(property.type); + + // Check options if present + if (property.type === 'options' || property.type === 'multiOptions') { + expect(property.options).toBeInstanceOf(Array); + expect(property.options.length).toBeGreaterThan(0); + + property.options.forEach((option: any) => { + expect(option).toHaveProperty('name'); + expect(option).toHaveProperty('value'); + }); + } + + // Check displayOptions if present + if (property.displayOptions) { + expect(property.displayOptions).toBeTypeOf('object'); + if (property.displayOptions.show) { + expect(property.displayOptions.show).toBeTypeOf('object'); + } + if (property.displayOptions.hide) { + expect(property.displayOptions.hide).toBeTypeOf('object'); + } + } +} + +/** + * Assert that an MCP tool response is valid + */ +export function expectValidMCPResponse(response: any) { + expect(response).toBeDefined(); + + // Check for error response + if (response.error) { + expect(response.error).toHaveProperty('code'); + expect(response.error).toHaveProperty('message'); + expect(typeof response.error.code).toBe('number'); + expect(typeof response.error.message).toBe('string'); + } else { + // Check for success response + expect(response.result).toBeDefined(); + } +} + +/** + * Assert that a database row has required metadata + */ +export function expectDatabaseMetadata(row: any) { + expect(row).toHaveProperty('created_at'); + expect(row).toHaveProperty('updated_at'); + + // Check dates are valid + const createdAt = new Date(row.created_at); + const updatedAt = new Date(row.updated_at); + + expect(createdAt.toString()).not.toBe('Invalid Date'); + expect(updatedAt.toString()).not.toBe('Invalid Date'); + expect(updatedAt.getTime()).toBeGreaterThanOrEqual(createdAt.getTime()); +} + +/** + * Assert that an expression is valid n8n expression syntax + */ +export function expectValidExpression(expression: string) { + // Check for basic expression syntax + const expressionPattern = /\{\{.*\}\}/; + expect(expression).toMatch(expressionPattern); + + // Check for balanced braces + let braceCount = 0; + for (const char of expression) { + if (char === '{') braceCount++; + if (char === '}') braceCount--; + expect(braceCount).toBeGreaterThanOrEqual(0); + } + expect(braceCount).toBe(0); +} + +/** + * Assert that a template is valid + */ +export function expectValidTemplate(template: any) { + expect(template).toBeDefined(); + expect(template).toHaveProperty('id'); + expect(template).toHaveProperty('name'); + expect(template).toHaveProperty('workflow'); + expect(template).toHaveProperty('categories'); + + // Check workflow is valid + expectValidWorkflow(template.workflow); + + // Check categories + expect(template.categories).toBeInstanceOf(Array); + expect(template.categories.length).toBeGreaterThan(0); +} + +/** + * Assert that search results are relevant + */ +export function expectRelevantSearchResults( + results: any[], + query: string, + minRelevance = 0.5 +) { + expect(results).toBeInstanceOf(Array); + + if (results.length === 0) return; + + // Check each result contains query terms + const queryTerms = query.toLowerCase().split(/\s+/); + + results.forEach(result => { + const searchableText = JSON.stringify(result).toLowerCase(); + const matchCount = queryTerms.filter(term => + searchableText.includes(term) + ).length; + + const relevance = matchCount / queryTerms.length; + expect(relevance).toBeGreaterThanOrEqual(minRelevance); + }); +} + +/** + * Custom matchers for n8n-mcp + */ +export const customMatchers = { + toBeValidNodeDefinition(received: any) { + try { + expectValidNodeDefinition(received); + return { pass: true, message: () => 'Node definition is valid' }; + } catch (error: any) { + return { pass: false, message: () => error.message }; + } + }, + + toBeValidWorkflow(received: any) { + try { + expectValidWorkflow(received); + return { pass: true, message: () => 'Workflow is valid' }; + } catch (error: any) { + return { pass: false, message: () => error.message }; + } + }, + + toContainValidationError(received: any[], expected: string) { + const errorMessages = received.map(e => + typeof e === 'string' ? e : e.message || e.error || String(e) + ); + + const found = errorMessages.some(msg => + msg.toLowerCase().includes(expected.toLowerCase()) + ); + + return { + pass: found, + message: () => found + ? `Found validation error containing "${expected}"` + : `No validation error found containing "${expected}". Errors: ${errorMessages.join(', ')}` + }; + } +}; \ No newline at end of file diff --git a/tests/utils/data-generators.ts b/tests/utils/data-generators.ts new file mode 100644 index 0000000..b3571f7 --- /dev/null +++ b/tests/utils/data-generators.ts @@ -0,0 +1,353 @@ +import { faker } from '@faker-js/faker'; +import { INodeDefinition, INode, IWorkflow } from '@/types/n8n-api'; + +/** + * Data generators for creating realistic test data + */ + +/** + * Generate a random node type + */ +export function generateNodeType(): string { + const packages = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']; + const nodeTypes = [ + 'webhook', 'httpRequest', 'slack', 'googleSheets', 'postgres', + 'function', 'code', 'if', 'switch', 'merge', 'splitInBatches', + 'emailSend', 'redis', 'mongodb', 'mysql', 'ftp', 'ssh' + ]; + + const pkg = faker.helpers.arrayElement(packages); + const type = faker.helpers.arrayElement(nodeTypes); + + return `${pkg}.${type}`; +} + +/** + * Generate property definitions for a node + */ +export function generateProperties(count = 5): any[] { + const properties = []; + + for (let i = 0; i < count; i++) { + const type = faker.helpers.arrayElement([ + 'string', 'number', 'boolean', 'options', 'collection' + ]); + + const property: any = { + displayName: faker.helpers.arrayElement([ + 'Resource', 'Operation', 'Field', 'Value', 'Method', + 'URL', 'Headers', 'Body', 'Authentication', 'Options' + ]), + name: faker.helpers.slugify(faker.word.noun()).toLowerCase(), + type, + default: generateDefaultValue(type), + description: faker.lorem.sentence() + }; + + if (type === 'options') { + property.options = generateOptions(); + } + + if (faker.datatype.boolean()) { + property.required = true; + } + + if (faker.datatype.boolean()) { + property.displayOptions = generateDisplayOptions(); + } + + properties.push(property); + } + + return properties; +} + +/** + * Generate default value based on type + */ +function generateDefaultValue(type: string): any { + switch (type) { + case 'string': + return faker.lorem.word(); + case 'number': + return faker.number.int({ min: 0, max: 100 }); + case 'boolean': + return faker.datatype.boolean(); + case 'options': + return 'option1'; + case 'collection': + return {}; + default: + return ''; + } +} + +/** + * Generate options for select fields + */ +function generateOptions(count = 3): any[] { + const options = []; + + for (let i = 0; i < count; i++) { + options.push({ + name: faker.helpers.arrayElement([ + 'Create', 'Read', 'Update', 'Delete', 'List', + 'Get', 'Post', 'Put', 'Patch', 'Send' + ]), + value: `option${i + 1}`, + description: faker.lorem.sentence() + }); + } + + return options; +} + +/** + * Generate display options for conditional fields + */ +function generateDisplayOptions(): any { + return { + show: { + resource: [faker.helpers.arrayElement(['user', 'post', 'message'])], + operation: [faker.helpers.arrayElement(['create', 'update', 'get'])] + } + }; +} + +/** + * Generate a complete node definition + */ +export function generateNodeDefinition(overrides?: Partial): INodeDefinition { + const nodeCategory = faker.helpers.arrayElement([ + 'Core Nodes', 'Communication', 'Data Transformation', + 'Development', 'Files', 'Productivity', 'Analytics' + ]); + + return { + displayName: faker.company.name() + ' Node', + name: faker.helpers.slugify(faker.company.name()).toLowerCase(), + group: [faker.helpers.arrayElement(['trigger', 'transform', 'output'])], + version: faker.number.float({ min: 1, max: 3, fractionDigits: 1 }), + subtitle: `={{$parameter["operation"] + ": " + $parameter["resource"]}}`, + description: faker.lorem.paragraph(), + defaults: { + name: faker.company.name(), + color: faker.color.rgb() + }, + inputs: ['main'], + outputs: ['main'], + credentials: faker.datatype.boolean() ? [{ + name: faker.helpers.slugify(faker.company.name()).toLowerCase() + 'Api', + required: true + }] : undefined, + properties: generateProperties(), + codex: { + categories: [nodeCategory], + subcategories: { + [nodeCategory]: [faker.word.noun()] + }, + alias: [faker.word.noun(), faker.word.verb()] + }, + ...overrides + }; +} + +/** + * Generate workflow nodes + */ +export function generateWorkflowNodes(count = 3): INode[] { + const nodes: INode[] = []; + + for (let i = 0; i < count; i++) { + nodes.push({ + id: faker.string.uuid(), + name: faker.helpers.arrayElement([ + 'Webhook', 'HTTP Request', 'Set', 'Function', 'IF', + 'Slack', 'Email', 'Database', 'Code' + ]) + (i > 0 ? i : ''), + type: generateNodeType(), + typeVersion: faker.number.float({ min: 1, max: 3, fractionDigits: 1 }), + position: [ + 250 + i * 200, + 300 + (i % 2) * 100 + ], + parameters: generateNodeParameters() + }); + } + + return nodes; +} + +/** + * Generate node parameters + */ +function generateNodeParameters(): Record { + const params: Record = {}; + + // Common parameters + if (faker.datatype.boolean()) { + params.resource = faker.helpers.arrayElement(['user', 'post', 'message']); + params.operation = faker.helpers.arrayElement(['create', 'get', 'update', 'delete']); + } + + // Type-specific parameters + if (faker.datatype.boolean()) { + params.url = faker.internet.url(); + } + + if (faker.datatype.boolean()) { + params.method = faker.helpers.arrayElement(['GET', 'POST', 'PUT', 'DELETE']); + } + + if (faker.datatype.boolean()) { + params.authentication = faker.helpers.arrayElement(['none', 'basicAuth', 'oAuth2']); + } + + // Add some random parameters + const randomParamCount = faker.number.int({ min: 1, max: 5 }); + for (let i = 0; i < randomParamCount; i++) { + const key = faker.word.noun().toLowerCase(); + params[key] = faker.helpers.arrayElement([ + faker.lorem.word(), + faker.number.int(), + faker.datatype.boolean(), + '={{ $json.data }}' + ]); + } + + return params; +} + +/** + * Generate workflow connections + */ +export function generateConnections(nodes: INode[]): Record { + const connections: Record = {}; + + // Connect nodes sequentially + for (let i = 0; i < nodes.length - 1; i++) { + const sourceId = nodes[i].id; + const targetId = nodes[i + 1].id; + + if (!connections[sourceId]) { + connections[sourceId] = { main: [[]] }; + } + + connections[sourceId].main[0].push({ + node: targetId, + type: 'main', + index: 0 + }); + } + + // Add some random connections + if (nodes.length > 2 && faker.datatype.boolean()) { + const sourceIdx = faker.number.int({ min: 0, max: nodes.length - 2 }); + const targetIdx = faker.number.int({ min: sourceIdx + 1, max: nodes.length - 1 }); + + const sourceId = nodes[sourceIdx].id; + const targetId = nodes[targetIdx].id; + + if (connections[sourceId]?.main[0]) { + connections[sourceId].main[0].push({ + node: targetId, + type: 'main', + index: 0 + }); + } + } + + return connections; +} + +/** + * Generate a complete workflow + */ +export function generateWorkflow(nodeCount = 3): IWorkflow { + const nodes = generateWorkflowNodes(nodeCount); + + return { + id: faker.string.uuid(), + name: faker.helpers.arrayElement([ + 'Data Processing Workflow', + 'API Integration Flow', + 'Notification Pipeline', + 'ETL Process', + 'Webhook Handler' + ]), + active: faker.datatype.boolean(), + nodes, + connections: generateConnections(nodes), + settings: { + executionOrder: 'v1', + saveManualExecutions: true, + callerPolicy: 'workflowsFromSameOwner', + timezone: faker.location.timeZone() + }, + staticData: {}, + tags: generateTags(), + createdAt: faker.date.past().toISOString(), + updatedAt: faker.date.recent().toISOString() + }; +} + +/** + * Generate workflow tags + */ +function generateTags(): Array<{ id: string; name: string }> { + const tagCount = faker.number.int({ min: 0, max: 3 }); + const tags = []; + + for (let i = 0; i < tagCount; i++) { + tags.push({ + id: faker.string.uuid(), + name: faker.helpers.arrayElement([ + 'production', 'development', 'testing', + 'automation', 'integration', 'notification' + ]) + }); + } + + return tags; +} + +/** + * Generate test templates + */ +export function generateTemplate() { + const workflow = generateWorkflow(); + + return { + id: faker.number.int({ min: 1000, max: 9999 }), + name: workflow.name, + description: faker.lorem.paragraph(), + workflow, + categories: faker.helpers.arrayElements([ + 'Sales', 'Marketing', 'Engineering', + 'HR', 'Finance', 'Operations' + ], { min: 1, max: 3 }), + useCases: faker.helpers.arrayElements([ + 'Lead Generation', 'Data Sync', 'Notifications', + 'Reporting', 'Automation', 'Integration' + ], { min: 1, max: 3 }), + views: faker.number.int({ min: 0, max: 10000 }), + recentViews: faker.number.int({ min: 0, max: 100 }) + }; +} + +/** + * Generate bulk test data + */ +export function generateBulkData(counts: { + nodes?: number; + workflows?: number; + templates?: number; +}) { + const { nodes = 10, workflows = 5, templates = 3 } = counts; + + return { + nodes: Array.from({ length: nodes }, () => generateNodeDefinition()), + workflows: Array.from({ length: workflows }, () => generateWorkflow()), + templates: Array.from({ length: templates }, () => generateTemplate()) + }; +} \ No newline at end of file diff --git a/tests/utils/test-helpers.ts b/tests/utils/test-helpers.ts new file mode 100644 index 0000000..ff61b5b --- /dev/null +++ b/tests/utils/test-helpers.ts @@ -0,0 +1,302 @@ +import { vi } from 'vitest'; +import { INodeDefinition, INode, IWorkflow } from '@/types/n8n-api'; + +/** + * Common test utilities and helpers + */ + +/** + * Wait for a condition to be true + */ +export async function waitFor( + condition: () => boolean | Promise, + options: { timeout?: number; interval?: number } = {} +): Promise { + const { timeout = 5000, interval = 50 } = options; + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (await condition()) { + return; + } + await new Promise(resolve => setTimeout(resolve, interval)); + } + + throw new Error(`Timeout waiting for condition after ${timeout}ms`); +} + +/** + * Create a mock node definition with default values + */ +export function createMockNodeDefinition(overrides?: Partial): INodeDefinition { + return { + displayName: 'Mock Node', + name: 'mockNode', + group: ['transform'], + version: 1, + description: 'A mock node for testing', + defaults: { + name: 'Mock Node', + }, + inputs: ['main'], + outputs: ['main'], + properties: [], + ...overrides + }; +} + +/** + * Create a mock workflow node + */ +export function createMockNode(overrides?: Partial): INode { + return { + id: 'mock-node-id', + name: 'Mock Node', + type: 'n8n-nodes-base.mockNode', + typeVersion: 1, + position: [0, 0], + parameters: {}, + ...overrides + }; +} + +/** + * Create a mock workflow + */ +export function createMockWorkflow(overrides?: Partial): IWorkflow { + return { + id: 'mock-workflow-id', + name: 'Mock Workflow', + active: false, + nodes: [], + connections: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides + }; +} + +/** + * Mock console methods for tests + */ +export function mockConsole() { + const originalConsole = { ...console }; + + const mocks = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + debug: vi.spyOn(console, 'debug').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}) + }; + + return { + mocks, + restore: () => { + Object.entries(mocks).forEach(([key, mock]) => { + mock.mockRestore(); + }); + } + }; +} + +/** + * Create a deferred promise for testing async operations + */ +export function createDeferred() { + let resolve: (value: T) => void; + let reject: (error: any) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { + promise, + resolve: resolve!, + reject: reject! + }; +} + +/** + * Helper to test error throwing + */ +export async function expectToThrowAsync( + fn: () => Promise, + errorMatcher?: string | RegExp | Error +) { + let thrown = false; + let error: any; + + try { + await fn(); + } catch (e) { + thrown = true; + error = e; + } + + if (!thrown) { + throw new Error('Expected function to throw'); + } + + if (errorMatcher) { + if (typeof errorMatcher === 'string') { + expect(error.message).toContain(errorMatcher); + } else if (errorMatcher instanceof RegExp) { + expect(error.message).toMatch(errorMatcher); + } else if (errorMatcher instanceof Error) { + expect(error).toEqual(errorMatcher); + } + } + + return error; +} + +/** + * Create a test database with initial data + */ +export function createTestDatabase(data: Record = {}) { + const db = new Map(); + + // Initialize with default tables + db.set('nodes', data.nodes || []); + db.set('templates', data.templates || []); + db.set('tools_documentation', data.tools_documentation || []); + + // Add any additional tables from data + Object.entries(data).forEach(([table, rows]) => { + if (!db.has(table)) { + db.set(table, rows); + } + }); + + return { + prepare: vi.fn((sql: string) => { + const tableName = extractTableName(sql); + const rows = db.get(tableName) || []; + + return { + all: vi.fn(() => rows), + get: vi.fn((params: any) => { + if (typeof params === 'string') { + return rows.find((r: any) => r.id === params); + } + return rows[0]; + }), + run: vi.fn((params: any) => { + rows.push(params); + return { changes: 1, lastInsertRowid: rows.length }; + }) + }; + }), + exec: vi.fn(), + close: vi.fn(), + transaction: vi.fn((fn: Function) => fn()), + pragma: vi.fn() + }; +} + +/** + * Extract table name from SQL query + */ +function extractTableName(sql: string): string { + const patterns = [ + /FROM\s+(\w+)/i, + /INTO\s+(\w+)/i, + /UPDATE\s+(\w+)/i, + /TABLE\s+(\w+)/i + ]; + + for (const pattern of patterns) { + const match = sql.match(pattern); + if (match) { + return match[1]; + } + } + + return 'nodes'; +} + +/** + * Create a mock HTTP response + */ +export function createMockResponse(data: any, status = 200) { + return { + data, + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: {}, + config: {} + }; +} + +/** + * Create a mock HTTP error + */ +export function createMockHttpError(message: string, status = 500, data?: any) { + const error: any = new Error(message); + error.isAxiosError = true; + error.response = { + data: data || { message }, + status, + statusText: status === 500 ? 'Internal Server Error' : 'Error', + headers: {}, + config: {} + }; + return error; +} + +/** + * Helper to test MCP tool calls + */ +export async function testMCPToolCall( + tool: any, + args: any, + expectedResult?: any +) { + const result = await tool.handler(args); + + if (expectedResult !== undefined) { + expect(result).toEqual(expectedResult); + } + + return result; +} + +/** + * Create a mock MCP context + */ +export function createMockMCPContext() { + return { + request: vi.fn(), + notify: vi.fn(), + expose: vi.fn(), + onClose: vi.fn() + }; +} + +/** + * Snapshot serializer for dates + */ +export const dateSerializer = { + test: (value: any) => value instanceof Date, + serialize: (value: Date) => value.toISOString() +}; + +/** + * Snapshot serializer for functions + */ +export const functionSerializer = { + test: (value: any) => typeof value === 'function', + serialize: () => '[Function]' +}; + +/** + * Clean up test environment + */ +export function cleanupTestEnvironment() { + vi.clearAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); +} \ No newline at end of file