import type { INode, INodeTypeDescription } from 'n8n-workflow'; import { identifyPinDataNodes, buildSchemaContexts, workflowToMermaid, generateEvalPinData, } from './pin-data-generator'; import type { SimpleWorkflow } from '../../src/types/workflow'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeNode(overrides: Partial & { name: string; type: string }): INode { return { name: overrides.name, type: overrides.type, typeVersion: overrides.typeVersion ?? 1, position: overrides.position ?? [0, 0], parameters: overrides.parameters ?? {}, id: overrides.id ?? overrides.name, ...(overrides.disabled !== undefined ? { disabled: overrides.disabled } : {}), } as INode; } function makeNodeType( name: string, opts?: { credentials?: Array<{ name: string }> }, ): INodeTypeDescription { return { displayName: name, name, group: ['transform'], version: 1, description: '', defaults: { name }, inputs: ['main'], outputs: ['main'], properties: [], ...(opts?.credentials ? { credentials: opts.credentials } : {}), } as unknown as INodeTypeDescription; } // --------------------------------------------------------------------------- // identifyPinDataNodes // --------------------------------------------------------------------------- describe('identifyPinDataNodes', () => { const nodeTypes: INodeTypeDescription[] = [ makeNodeType('n8n-nodes-base.slack', { credentials: [{ name: 'slackApi' }] }), makeNodeType('n8n-nodes-base.gmail', { credentials: [{ name: 'gmailOAuth2' }] }), makeNodeType('n8n-nodes-base.set'), makeNodeType('n8n-nodes-base.if'), makeNodeType('n8n-nodes-base.httpRequest'), makeNodeType('n8n-nodes-base.webhook'), ]; it('should include service nodes with credentials', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })], connections: {}, }; const result = identifyPinDataNodes(workflow, nodeTypes); expect(result).toHaveLength(1); expect(result[0].name).toBe('Slack'); }); it('should exclude utility nodes that can run without infrastructure', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Set', type: 'n8n-nodes-base.set' }), makeNode({ name: 'If', type: 'n8n-nodes-base.if' }), makeNode({ name: 'Merge', type: 'n8n-nodes-base.merge' }), ], connections: {}, }; const result = identifyPinDataNodes(workflow, nodeTypes); expect(result).toHaveLength(0); }); it('should include Code and ExecuteCommand nodes (require task runner)', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Code', type: 'n8n-nodes-base.code' }), makeNode({ name: 'Exec', type: 'n8n-nodes-base.executeCommand' }), ], connections: {}, }; const result = identifyPinDataNodes(workflow, nodeTypes); expect(result).toHaveLength(2); expect(result.map((n) => n.name)).toEqual(['Code', 'Exec']); }); it('should include DataTable nodes', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Table', type: 'n8n-nodes-base.dataTable' })], connections: {}, }; const result = identifyPinDataNodes(workflow, nodeTypes); expect(result).toHaveLength(1); expect(result[0].name).toBe('Table'); }); it('should include root AI nodes targeted by ai_* connections', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Chat Trigger', type: '@n8n/n8n-nodes-langchain.chatTrigger' }), makeNode({ name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent' }), makeNode({ name: 'OpenAI', type: '@n8n/n8n-nodes-langchain.lmOpenAi' }), makeNode({ name: 'Tool', type: '@n8n/n8n-nodes-langchain.toolCalculator' }), ], connections: { 'Chat Trigger': { main: [[{ node: 'Agent', type: 'main', index: 0 }]], }, OpenAI: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]], }, Tool: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]], }, }, }; const result = identifyPinDataNodes(workflow, nodeTypes); const names = result.map((n) => n.name); // Agent is a root AI node (target of ai_* connections) → pinned expect(names).toContain('Agent'); // Sub-nodes (OpenAI, Tool) are not pinned — they won't execute // because the root is pinned expect(names).not.toContain('OpenAI'); expect(names).not.toContain('Tool'); }); it('should not treat nodes targeted only by main connections as AI roots', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }), makeNode({ name: 'Set', type: 'n8n-nodes-base.set' }), ], connections: { Trigger: { main: [[{ node: 'Set', type: 'main', index: 0 }]], }, }, }; const result = identifyPinDataNodes(workflow, nodeTypes); expect(result).toHaveLength(0); }); it('should include HTTP Request and Webhook nodes even without credentials', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'HTTP', type: 'n8n-nodes-base.httpRequest' }), makeNode({ name: 'Hook', type: 'n8n-nodes-base.webhook' }), ], connections: {}, }; const result = identifyPinDataNodes(workflow, nodeTypes); expect(result).toHaveLength(2); }); it('should skip disabled nodes', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack', disabled: true })], connections: {}, }; const result = identifyPinDataNodes(workflow, nodeTypes); expect(result).toHaveLength(0); }); it('should handle mixed utility and service nodes', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }), makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' }), makeNode({ name: 'Set', type: 'n8n-nodes-base.set' }), makeNode({ name: 'Gmail', type: 'n8n-nodes-base.gmail' }), ], connections: {}, }; const result = identifyPinDataNodes(workflow, nodeTypes); expect(result).toHaveLength(2); expect(result.map((n) => n.name)).toEqual(['Slack', 'Gmail']); }); }); // --------------------------------------------------------------------------- // buildSchemaContexts // --------------------------------------------------------------------------- describe('buildSchemaContexts', () => { it('should extract resource and operation from node parameters', () => { const nodes: INode[] = [ makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack', typeVersion: 2, parameters: { resource: 'message', operation: 'send' }, }), ]; const contexts = buildSchemaContexts(nodes); expect(contexts).toHaveLength(1); expect(contexts[0]).toMatchObject({ nodeName: 'Slack', nodeType: 'n8n-nodes-base.slack', typeVersion: 2, resource: 'message', operation: 'send', }); }); it('should handle nodes without resource/operation', () => { const nodes: INode[] = [ makeNode({ name: 'HTTP', type: 'n8n-nodes-base.httpRequest', parameters: { url: 'https://example.com' }, }), ]; const contexts = buildSchemaContexts(nodes); expect(contexts[0].resource).toBeUndefined(); expect(contexts[0].operation).toBeUndefined(); }); it('should not include schema when nodesBasePath is not provided', () => { const nodes: INode[] = [ makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack', parameters: { resource: 'message', operation: 'send' }, }), ]; const contexts = buildSchemaContexts(nodes); expect(contexts[0].schema).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // workflowToMermaid // --------------------------------------------------------------------------- describe('workflowToMermaid', () => { it('should generate mermaid flowchart for a simple workflow', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }), makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack', typeVersion: 2 }), ], connections: { Trigger: { main: [[{ node: 'Slack', type: 'main', index: 0 }]], }, }, }; const mermaid = workflowToMermaid(workflow); expect(mermaid).toContain('flowchart LR'); expect(mermaid).toContain('Trigger'); expect(mermaid).toContain('Slack'); expect(mermaid).toContain('-->'); }); it('should include resource and operation in labels', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack', typeVersion: 2, parameters: { resource: 'message', operation: 'send' }, }), ], connections: {}, }; const mermaid = workflowToMermaid(workflow); expect(mermaid).toContain('resource:message'); expect(mermaid).toContain('op:send'); }); it('should handle empty connections', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' })], connections: {}, }; const mermaid = workflowToMermaid(workflow); expect(mermaid).toContain('flowchart LR'); expect(mermaid).not.toContain('-->'); }); it('should handle multi-output connections', () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'If', type: 'n8n-nodes-base.if' }), makeNode({ name: 'True', type: 'n8n-nodes-base.set' }), makeNode({ name: 'False', type: 'n8n-nodes-base.set' }), ], connections: { If: { main: [ [{ node: 'True', type: 'main', index: 0 }], [{ node: 'False', type: 'main', index: 0 }], ], }, }, }; const mermaid = workflowToMermaid(workflow); // Should have two connection lines const arrowCount = (mermaid.match(/-->/g) ?? []).length; expect(arrowCount).toBe(2); }); }); // --------------------------------------------------------------------------- // generateEvalPinData (with mocked LLM) // --------------------------------------------------------------------------- describe('generateEvalPinData', () => { const nodeTypes: INodeTypeDescription[] = [ makeNodeType('n8n-nodes-base.slack', { credentials: [{ name: 'slackApi' }] }), makeNodeType('n8n-nodes-base.linear', { credentials: [{ name: 'linearApi' }] }), makeNodeType('n8n-nodes-base.set'), ]; function createMockLLM(responseContent: string) { return { invoke: jest.fn().mockResolvedValue({ content: responseContent }), } as never; } it('should return pin data for service nodes', async () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Linear', type: 'n8n-nodes-base.linear' }), makeNode({ name: 'Set', type: 'n8n-nodes-base.set' }), makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' }), ], connections: {}, }; const llmResponse = JSON.stringify({ Linear: [{ json: { id: 'issue-123', title: 'Test Issue' } }], Slack: [{ json: { ok: true, channel: 'C123' } }], }); const result = await generateEvalPinData(workflow, { llm: createMockLLM(llmResponse), nodeTypes, }); expect(Object.keys(result)).toEqual(['Linear', 'Slack']); expect(result.Linear).toHaveLength(1); expect(result.Slack).toHaveLength(1); }); it('should return empty object when no service nodes exist', async () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Set', type: 'n8n-nodes-base.set' })], connections: {}, }; const llm = createMockLLM('{}'); const result = await generateEvalPinData(workflow, { llm, nodeTypes }); expect(result).toEqual({}); // LLM should not be called expect((llm as unknown as { invoke: jest.Mock }).invoke).not.toHaveBeenCalled(); }); it('should handle markdown-fenced JSON response', async () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })], connections: {}, }; const llmResponse = '```json\n{"Slack": [{"json": {"ok": true}}]}\n```'; const result = await generateEvalPinData(workflow, { llm: createMockLLM(llmResponse), nodeTypes, }); expect(result.Slack).toHaveLength(1); }); it('should wrap raw objects in { json: ... } format', async () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })], connections: {}, }; // LLM returns items without the { json: ... } wrapper const llmResponse = JSON.stringify({ Slack: [{ ok: true, channel: 'C123' }], }); const result = await generateEvalPinData(workflow, { llm: createMockLLM(llmResponse), nodeTypes, }); expect(result.Slack).toHaveLength(1); expect(result.Slack[0]).toHaveProperty('json'); }); it('should return empty object on LLM failure', async () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })], connections: {}, }; const llm = { invoke: jest.fn().mockRejectedValue(new Error('LLM error')), } as never; const result = await generateEvalPinData(workflow, { llm, nodeTypes }); expect(result).toEqual({}); }); it('should return empty object on invalid JSON response', async () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })], connections: {}, }; const result = await generateEvalPinData(workflow, { llm: createMockLLM('not valid json at all'), nodeTypes, }); expect(result).toEqual({}); }); it('should skip nodes not present in LLM response', async () => { const workflow: SimpleWorkflow = { name: 'Test', nodes: [ makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' }), makeNode({ name: 'Linear', type: 'n8n-nodes-base.linear' }), ], connections: {}, }; // LLM only returns data for Slack, not Linear const llmResponse = JSON.stringify({ Slack: [{ json: { ok: true } }], }); const result = await generateEvalPinData(workflow, { llm: createMockLLM(llmResponse), nodeTypes, }); expect(Object.keys(result)).toEqual(['Slack']); }); });