n8n/packages/workflow/test/telemetry-helpers.test.ts

4223 lines
104 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
import { mock } from 'vitest-mock-extended';
import { nodeTypes } from './ExpressionExtensions/helpers';
import type { NodeTypes } from './node-types';
import {
MCP_CLIENT_NODE_TYPE,
MCP_CLIENT_TOOL_NODE_TYPE,
STICKY_NODE_TYPE,
} from '../src/constants';
import { ApplicationError, ExpressionError, NodeApiError } from '../src/errors';
import type {
IConnections,
INode,
INodeTypeDescription,
IRun,
IRunData,
NodeConnectionType,
IWorkflowBase,
INodeParameters,
} from '../src/interfaces';
import { NodeConnectionTypes } from '../src/interfaces';
import * as nodeHelpers from '../src/node-helpers';
import {
ANONYMIZATION_CHARACTER as CHAR,
extractLastExecutedNodeCredentialData,
extractLastExecutedNodeStructuredOutputErrorInfo,
generateNodesGraph,
getDomainBase,
getDomainPath,
getNodeRole,
resolveAIMetrics,
resolveVectorStoreMetrics,
userInInstanceRanOutOfFreeAiCredits,
} from '../src/telemetry-helpers';
import { randomInt } from '../src/utils';
import { DEFAULT_EVALUATION_METRIC } from '../src/evaluation-helpers';
vi.mock('../src/node-helpers', async () => {
const actual = await vi.importActual<typeof import('../src/node-helpers')>('../src/node-helpers');
return {
...actual,
getNodeParameters: vi.fn().mockImplementation(actual.getNodeParameters),
};
});
describe('getDomainBase should return protocol plus domain', () => {
test('in valid URLs', () => {
for (const url of validUrls(numericId)) {
const { full, protocolPlusDomain } = url;
expect(getDomainBase(full)).toBe(protocolPlusDomain);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(numericId)) {
const { full, protocolPlusDomain } = url;
expect(getDomainBase(full)).toBe(protocolPlusDomain);
}
});
test('should handle multi-level TLDs correctly', () => {
// Test cases for multi-level TLDs (country codes, special services)
const testCases = [
{ input: 'https://api.example.co.uk/path', expected: 'example.co.uk' },
{ input: 'https://user.service.com.au/api', expected: 'service.com.au' },
{ input: 'https://api.example.co.jp/test', expected: 'example.co.jp' },
{ input: 'https://test.example.gov.uk/data', expected: 'example.gov.uk' },
{ input: 'https://sub.domain.org.nz/path', expected: 'domain.org.nz' },
{ input: 'api.example.co.uk/path', expected: 'example.co.uk' },
];
testCases.forEach(({ input, expected }) => {
expect(getDomainBase(input)).toBe(expected);
});
});
test('should handle edge cases', () => {
// Single word domains, localhost, etc.
expect(getDomainBase('https://localhost/path')).toBe('localhost');
expect(getDomainBase('https://example/path')).toBe('example');
});
test('should handle IP addresses', () => {
expect(getDomainBase('https://192.168.1.1/path')).toBe('192.168.1.1');
expect(getDomainBase('https://[::1]/path')).toBe('[::1]');
});
});
describe('getDomainPath should return pathname, excluding query string', () => {
describe('anonymizing strings containing at least one number', () => {
test('in valid URLs', () => {
for (const url of validUrls(alphanumericId)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(alphanumericId)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
describe('anonymizing UUIDs', () => {
test('in valid URLs', () => {
for (const url of uuidUrls(validUrls)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of uuidUrls(malformedUrls)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
describe('anonymizing emails', () => {
test('in valid URLs', () => {
for (const url of validUrls(email)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(email)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
});
describe('generateNodesGraph', () => {
test('should return node graph when node type is unknown', () => {
const workflow: IWorkflowBase = {
createdAt: new Date('2024-01-05T13:49:14.244Z'),
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
activeVersionId: null,
isArchived: false,
nodes: [
{
parameters: {},
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
name: 'When clicking "Execute Workflow"',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [420, 420],
},
{
parameters: {
documentId: { __rl: true, mode: 'list', value: '' },
sheetName: { __rl: true, mode: 'list', value: '' },
},
id: '266128b9-e5db-4c26-9555-185d48946afb',
name: 'Google Sheets',
type: 'test.unknown',
typeVersion: 4.2,
position: [640, 420],
},
],
connections: {
'When clicking "Execute Workflow"': {
main: [[{ node: 'Google Sheets', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
settings: { executionOrder: 'v1' },
pinData: {},
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: ['n8n-nodes-base.manualTrigger', 'test.unknown'],
node_connections: [{ start: '0', end: '1' }],
nodes: {
'0': {
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
type: 'n8n-nodes-base.manualTrigger',
version: 1,
position: [420, 420],
},
'1': {
id: '266128b9-e5db-4c26-9555-185d48946afb',
type: 'test.unknown',
version: 4.2,
position: [640, 420],
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph when workflow is empty', () => {
const workflow: IWorkflowBase = {
createdAt: new Date('2024-01-05T13:49:14.244Z'),
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
activeVersionId: null,
isArchived: false,
nodes: [],
connections: {},
settings: { executionOrder: 'v1' },
pinData: {},
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: [],
node_connections: [],
nodes: {},
notes: {},
is_pinned: false,
},
nameIndices: {},
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph when workflow keys are not set', () => {
const workflow: Partial<IWorkflowBase> = {};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: [],
node_connections: [],
nodes: {},
notes: {},
is_pinned: false,
},
nameIndices: {},
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph when node has multiple operation fields with different display options', () => {
const workflow: IWorkflowBase = {
createdAt: new Date('2024-01-05T13:49:14.244Z'),
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
activeVersionId: null,
isArchived: false,
nodes: [
{
parameters: {},
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
name: 'When clicking "Execute Workflow"',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [420, 420],
},
{
parameters: {
documentId: { __rl: true, mode: 'list', value: '' },
sheetName: { __rl: true, mode: 'list', value: '' },
},
id: '266128b9-e5db-4c26-9555-185d48946afb',
name: 'Google Sheets',
type: 'test.googleSheets',
typeVersion: 4.2,
position: [640, 420],
},
],
connections: {
'When clicking "Execute Workflow"': {
main: [[{ node: 'Google Sheets', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
settings: { executionOrder: 'v1' },
pinData: {},
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'],
node_connections: [{ start: '0', end: '1' }],
nodes: {
'0': {
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
type: 'n8n-nodes-base.manualTrigger',
version: 1,
position: [420, 420],
},
'1': {
id: '266128b9-e5db-4c26-9555-185d48946afb',
type: 'test.googleSheets',
version: 4.2,
position: [640, 420],
operation: 'read',
resource: 'sheet',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with stickies of default size', () => {
const workflow: IWorkflowBase = {
createdAt: new Date('2024-01-05T13:49:14.244Z'),
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
activeVersionId: null,
isArchived: false,
nodes: [
{
parameters: {},
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
name: 'When clicking "Execute Workflow"',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [420, 420],
},
{
parameters: {
documentId: { __rl: true, mode: 'list', value: '' },
sheetName: { __rl: true, mode: 'list', value: '' },
},
id: '266128b9-e5db-4c26-9555-185d48946afb',
name: 'Google Sheets',
type: 'test.googleSheets',
typeVersion: 4.2,
position: [640, 420],
},
{
parameters: {
content:
"test\n\n## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/components/sticky-notes/)",
},
id: '03e85c3e-4303-4f93-8d62-e05d457e8f70',
name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [240, 140],
},
],
connections: {
'When clicking "Execute Workflow"': {
main: [[{ node: 'Google Sheets', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
settings: { executionOrder: 'v1' },
pinData: {},
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'],
node_connections: [{ start: '0', end: '1' }],
nodes: {
'0': {
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
type: 'n8n-nodes-base.manualTrigger',
version: 1,
position: [420, 420],
},
'1': {
id: '266128b9-e5db-4c26-9555-185d48946afb',
type: 'test.googleSheets',
version: 4.2,
position: [640, 420],
operation: 'read',
resource: 'sheet',
},
},
notes: { '0': { overlapping: false, position: [240, 140], height: 160, width: 240 } },
is_pinned: false,
},
nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with agent node and all prompt types when cloud telemetry is enabled', () => {
const optionalPrompts = {
humanMessage: 'Human message',
systemMessage: 'System message',
humanMessageTemplate: 'Human template',
prefix: 'Prefix',
suffixChat: 'Suffix Chat',
suffix: 'Suffix',
prefixPrompt: 'Prefix Prompt',
suffixPrompt: 'Suffix Prompt',
};
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
agent: 'toolsAgent',
text: 'Agent prompt text',
options: {
...optionalPrompts,
},
},
id: 'agent-node-id',
name: 'Agent Node',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 1,
position: [100, 100],
},
{
parameters: {},
id: 'other-node-id',
name: 'Other Node',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [200, 200],
},
],
connections: {
'Agent Node': {
main: [[{ node: 'Other Node', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.set'],
node_connections: [{ start: '0', end: '1' }],
nodes: {
'0': {
id: 'agent-node-id',
type: '@n8n/n8n-nodes-langchain.agent',
version: 1,
position: [100, 100],
agent: 'toolsAgent',
prompts: { text: 'Agent prompt text', ...optionalPrompts },
},
'1': {
id: 'other-node-id',
type: 'n8n-nodes-base.set',
version: 1,
position: [200, 200],
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Agent Node': '0', 'Other Node': '1' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with agent node without prompt types when cloud telemetry is disbaled', () => {
const optionalPrompts = {
humanMessage: 'Human message',
systemMessage: 'System message',
humanMessageTemplate: 'Human template',
prefix: 'Prefix',
suffixChat: 'Suffix Chat',
suffix: 'Suffix',
prefixPrompt: 'Prefix Prompt',
suffixPrompt: 'Suffix Prompt',
};
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
agent: 'toolsAgent',
text: 'Agent prompt text',
options: {
...optionalPrompts,
},
},
id: 'agent-node-id',
name: 'Agent Node',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 1,
position: [100, 100],
},
{
parameters: {},
id: 'other-node-id',
name: 'Other Node',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [200, 200],
},
],
connections: {
'Agent Node': {
main: [[{ node: 'Other Node', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: false })).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.set'],
node_connections: [{ start: '0', end: '1' }],
nodes: {
'0': {
id: 'agent-node-id',
type: '@n8n/n8n-nodes-langchain.agent',
version: 1,
position: [100, 100],
agent: 'toolsAgent',
},
'1': {
id: 'other-node-id',
type: 'n8n-nodes-base.set',
version: 1,
position: [200, 200],
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Agent Node': '0', 'Other Node': '1' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with agent tool node and prompt text when cloud telemetry is enabled', () => {
const optionalPrompts = {
humanMessage: 'Human message',
systemMessage: 'System message',
humanMessageTemplate: 'Human template',
prefix: 'Prefix',
suffixChat: 'Suffix Chat',
suffix: 'Suffix',
prefixPrompt: 'Prefix Prompt',
suffixPrompt: 'Suffix Prompt',
};
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
text: 'Tool agent prompt',
options: {
...optionalPrompts,
},
},
id: 'agent-tool-node-id',
name: 'Agent Tool Node',
type: '@n8n/n8n-nodes-langchain.agentTool',
typeVersion: 1,
position: [300, 300],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.agentTool'],
node_connections: [],
nodes: {
'0': {
id: 'agent-tool-node-id',
type: '@n8n/n8n-nodes-langchain.agentTool',
version: 1,
position: [300, 300],
prompts: { text: 'Tool agent prompt', ...optionalPrompts },
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Agent Tool Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with openai langchain node and prompts array when cloud telemetry is enabled', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
messages: {
values: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello!' },
],
},
},
id: 'openai-node-id',
name: 'OpenAI Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [400, 400],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.lmChatOpenAi'],
node_connections: [],
nodes: {
'0': {
id: 'openai-node-id',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
use_responses_api: false,
version: 1,
position: [400, 400],
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'OpenAI Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with chain summarization node and summarization prompts when cloud telemetry is enabled', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
options: {
summarizationMethodAndPrompts: {
values: { summaryPrompt: 'Summarize this text.' },
},
},
},
id: 'summarization-node-id',
name: 'Summarization Node',
type: '@n8n/n8n-nodes-langchain.chainSummarization',
typeVersion: 1,
position: [500, 500],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.chainSummarization'],
node_connections: [],
nodes: {
'0': {
id: 'summarization-node-id',
type: '@n8n/n8n-nodes-langchain.chainSummarization',
version: 1,
position: [500, 500],
prompts: { summaryPrompt: 'Summarize this text.' },
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Summarization Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with langchain custom tool node and description prompt when cloud telemetry is enabled', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
description: 'Custom tool description',
},
id: 'custom-tool-node-id',
name: 'Custom Tool Node',
type: '@n8n/n8n-nodes-langchain.customTool',
typeVersion: 1,
position: [600, 600],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.customTool'],
node_connections: [],
nodes: {
'0': {
id: 'custom-tool-node-id',
type: '@n8n/n8n-nodes-langchain.customTool',
version: 1,
position: [600, 600],
// prompts: { description: 'Custom tool description' },
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Custom Tool Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with chain llm node and messageValues prompts when cloud telemetry is enabled', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
messages: {
messageValues: [
{ role: 'system', content: 'Chain LLM system prompt.' },
{ role: 'user', content: 'Chain LLM user prompt.' },
],
},
},
id: 'chain-llm-node-id',
name: 'Chain LLM Node',
type: '@n8n/n8n-nodes-langchain.chainLlm',
typeVersion: 1,
position: [700, 700],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.chainLlm'],
node_connections: [],
nodes: {
'0': {
id: 'chain-llm-node-id',
type: '@n8n/n8n-nodes-langchain.chainLlm',
version: 1,
position: [700, 700],
prompts: [
{ role: 'system', content: 'Chain LLM system prompt.' },
{ role: 'user', content: 'Chain LLM user prompt.' },
],
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Chain LLM Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return node graph with stickies indicating overlap', () => {
const workflow: IWorkflowBase = {
createdAt: new Date('2024-01-05T13:49:14.244Z'),
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
activeVersionId: null,
isArchived: false,
nodes: [
{
parameters: {},
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
name: 'When clicking "Execute Workflow"',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [420, 420],
},
{
parameters: {
documentId: { __rl: true, mode: 'list', value: '' },
sheetName: { __rl: true, mode: 'list', value: '' },
},
id: '266128b9-e5db-4c26-9555-185d48946afb',
name: 'Google Sheets',
type: 'test.googleSheets',
typeVersion: 4.2,
position: [640, 420],
},
{
parameters: {
content:
"test\n\n## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/components/sticky-notes/)",
height: 488,
width: 645,
},
id: '03e85c3e-4303-4f93-8d62-e05d457e8f70',
name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [240, 140],
},
],
connections: {
'When clicking "Execute Workflow"': {
main: [[{ node: 'Google Sheets', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
settings: { executionOrder: 'v1' },
pinData: {},
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
evaluationTriggerNodeNames: [],
nodeGraph: {
node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'],
node_connections: [{ start: '0', end: '1' }],
nodes: {
'0': {
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
type: 'n8n-nodes-base.manualTrigger',
version: 1,
position: [420, 420],
},
'1': {
id: '266128b9-e5db-4c26-9555-185d48946afb',
type: 'test.googleSheets',
version: 4.2,
position: [640, 420],
operation: 'read',
resource: 'sheet',
},
},
notes: { '0': { overlapping: true, position: [240, 140], height: 488, width: 645 } },
is_pinned: false,
},
nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' },
webhookNodeNames: [],
});
});
test('should return node graph indicating pinned data', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {},
id: 'e59d3ad9-3448-4899-9f47-d2922c8727ce',
name: 'When clicking "Execute Workflow"',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [460, 460],
},
],
connections: {},
pinData: {
'When clicking "Execute Workflow"': [],
},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nameIndices: {
'When clicking "Execute Workflow"': '0',
},
nodeGraph: {
is_pinned: true,
node_connections: [],
node_types: ['n8n-nodes-base.manualTrigger'],
nodes: {
'0': {
id: 'e59d3ad9-3448-4899-9f47-d2922c8727ce',
position: [460, 460],
type: 'n8n-nodes-base.manualTrigger',
version: 1,
},
},
notes: {},
},
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return graph with webhook node', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
path: 'bf4c0699-cff8-4440-8964-8e97fda8b4f8',
options: {},
},
id: '5e49e129-2c59-4650-95ea-14d4b94db1f3',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1.1,
position: [520, 380],
webhookId: 'bf4c0699-cff8-4440-8964-8e97fda8b4f8',
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
evaluationTriggerNodeNames: [],
nodeGraph: {
node_types: ['n8n-nodes-base.webhook'],
node_connections: [],
nodes: {
'0': {
id: '5e49e129-2c59-4650-95ea-14d4b94db1f3',
type: 'n8n-nodes-base.webhook',
version: 1.1,
position: [520, 380],
response_mode: 'onReceived',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { Webhook: '0' },
webhookNodeNames: ['Webhook'],
});
});
test('should return graph with http v4 node with generic auth', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
url: 'google.com/path/test',
authentication: 'genericCredentialType',
genericAuthType: 'httpBasicAuth',
options: {},
},
id: '04d6e44f-09c1-454d-9225-60aeed7f022c',
name: 'HTTP Request V4 with generic auth',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.1,
position: [780, 120],
credentials: {
httpBasicAuth: {
id: 'yuuJAO2Ang5B64wd',
name: 'Unnamed credential',
},
},
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
evaluationTriggerNodeNames: [],
nodeGraph: {
node_types: ['n8n-nodes-base.httpRequest'],
node_connections: [],
nodes: {
'0': {
id: '04d6e44f-09c1-454d-9225-60aeed7f022c',
type: 'n8n-nodes-base.httpRequest',
version: 4.1,
position: [780, 120],
credential_type: 'httpBasicAuth',
credential_set: true,
domain_base: 'google.com',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'HTTP Request V4 with generic auth': '0' },
webhookNodeNames: [],
});
});
test('should return graph with HTTP V4 with predefined cred', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
url: 'google.com/path/test',
authentication: 'predefinedCredentialType',
nodeCredentialType: 'activeCampaignApi',
options: {},
},
id: 'dcc4a9e1-c2c5-4d7e-aec0-2a23adabbb77',
name: 'HTTP Request V4 with predefined cred',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.1,
position: [320, 220],
credentials: {
httpBasicAuth: {
id: 'yuuJAO2Ang5B64wd',
name: 'Unnamed credential',
},
activeCampaignApi: {
id: 'SFCbnfgRBuSzRu6N',
name: 'ActiveCampaign account',
},
},
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
evaluationTriggerNodeNames: [],
nodeGraph: {
node_types: ['n8n-nodes-base.httpRequest'],
node_connections: [],
nodes: {
'0': {
id: 'dcc4a9e1-c2c5-4d7e-aec0-2a23adabbb77',
type: 'n8n-nodes-base.httpRequest',
version: 4.1,
position: [320, 220],
credential_type: 'activeCampaignApi',
credential_set: true,
domain_base: 'google.com',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'HTTP Request V4 with predefined cred': '0' },
webhookNodeNames: [],
});
});
it.each([
{
workflow: {
nodes: [
{
parameters: {
mode: 'combineBySql',
query: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.name = input2.id',
},
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
name: 'Merge Node V3',
type: 'n8n-nodes-base.merge',
typeVersion: 3,
position: [320, 460],
},
],
connections: {},
pinData: {},
} as Partial<IWorkflowBase>,
isCloudDeployment: false,
expected: {
nodeGraph: {
node_types: ['n8n-nodes-base.merge'],
node_connections: [],
nodes: {
'0': {
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
type: 'n8n-nodes-base.merge',
version: 3,
position: [320, 460],
operation: 'combineBySql',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Merge Node V3': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
},
},
{
workflow: {
nodes: [
{
parameters: {
mode: 'append',
},
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
name: 'Merge Node V3',
type: 'n8n-nodes-base.merge',
typeVersion: 3,
position: [320, 460],
},
],
connections: {},
pinData: {},
} as Partial<IWorkflowBase>,
isCloudDeployment: true,
expected: {
nodeGraph: {
node_types: ['n8n-nodes-base.merge'],
node_connections: [],
nodes: {
'0': {
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
type: 'n8n-nodes-base.merge',
version: 3,
position: [320, 460],
operation: 'append',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Merge Node V3': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
},
},
{
workflow: {
nodes: [
{
parameters: {
mode: 'combineBySql',
query: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.name = input2.id',
},
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
name: 'Merge Node V3',
type: 'n8n-nodes-base.merge',
typeVersion: 3,
position: [320, 460],
},
],
connections: {},
pinData: {},
} as Partial<IWorkflowBase>,
isCloudDeployment: true,
expected: {
nodeGraph: {
node_types: ['n8n-nodes-base.merge'],
node_connections: [],
nodes: {
'0': {
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
type: 'n8n-nodes-base.merge',
version: 3,
position: [320, 460],
operation: 'combineBySql',
sql: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.name = input2.id',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Merge Node V3': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
},
},
])('should return graph with merge v3 node', ({ workflow, expected, isCloudDeployment }) => {
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment })).toEqual(expected);
});
test('should return graph with http v1 node', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
url: 'https://google.com',
options: {},
},
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
name: 'HTTP Request V1',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [320, 460],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: ['n8n-nodes-base.httpRequest'],
node_connections: [],
nodes: {
'0': {
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
type: 'n8n-nodes-base.httpRequest',
version: 1,
position: [320, 460],
domain: 'google.com',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'HTTP Request V1': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should return graph with http v4 node with no parameters and no credentials', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
options: {},
},
id: 'd002e66f-deba-455c-9f8b-65239db453c3',
name: 'HTTP Request v4 with defaults',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.1,
position: [600, 240],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: ['n8n-nodes-base.httpRequest'],
node_connections: [],
nodes: {
'0': {
id: 'd002e66f-deba-455c-9f8b-65239db453c3',
type: 'n8n-nodes-base.httpRequest',
version: 4.1,
position: [600, 240],
credential_set: false,
domain_base: '',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'HTTP Request v4 with defaults': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should support custom connections like in AI nodes', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {},
id: 'fe69383c-e418-4f98-9c0e-924deafa7f93',
name: 'When clicking Execute workflow',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [540, 220],
},
{
parameters: {},
id: 'c5c374f1-6fad-46bb-8eea-ceec126b300a',
name: 'Chain',
type: '@n8n/n8n-nodes-langchain.chainLlm',
typeVersion: 1,
position: [760, 320],
},
{
parameters: {
options: {},
},
id: '198133b6-95dd-4f7e-90e5-e16c4cdbad12',
name: 'Model',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [780, 500],
},
],
connections: {
'When clicking Execute workflow': {
main: [
[
{
node: 'Chain',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
Model: {
ai_languageModel: [
[
{
node: 'Chain',
type: NodeConnectionTypes.AiLanguageModel,
index: 0,
},
],
],
},
},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: [
'n8n-nodes-base.manualTrigger',
'@n8n/n8n-nodes-langchain.chainLlm',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
],
node_connections: [
{
start: '0',
end: '1',
},
{
start: '2',
end: '1',
},
],
nodes: {
'0': {
id: 'fe69383c-e418-4f98-9c0e-924deafa7f93',
type: 'n8n-nodes-base.manualTrigger',
version: 1,
position: [540, 220],
},
'1': {
id: 'c5c374f1-6fad-46bb-8eea-ceec126b300a',
type: '@n8n/n8n-nodes-langchain.chainLlm',
version: 1,
position: [760, 320],
},
'2': {
id: '198133b6-95dd-4f7e-90e5-e16c4cdbad12',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
use_responses_api: false,
version: 1,
position: [780, 500],
},
},
notes: {},
is_pinned: false,
},
nameIndices: {
'When clicking Execute workflow': '0',
Chain: '1',
Model: '2',
},
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should not fail on error to resolve a node parameter for sticky node type', () => {
const workflow = mock<IWorkflowBase>({ nodes: [{ type: STICKY_NODE_TYPE }], connections: {} });
vi.mocked(nodeHelpers.getNodeParameters).mockImplementationOnce(() => {
throw new ApplicationError('Could not find property option');
});
expect(() => generateNodesGraph(workflow, nodeTypes)).not.toThrow();
});
test('should add run and items count', () => {
const { workflow, runData } = generateTestWorkflowAndRunData();
expect(generateNodesGraph(workflow, nodeTypes, { runData })).toEqual({
nameIndices: {
DebugHelper: '4',
'Edit Fields': '1',
'Edit Fields1': '2',
'Edit Fields2': '3',
'Execute Workflow Trigger': '0',
Switch: '5',
},
nodeGraph: {
is_pinned: false,
node_connections: [
{
end: '1',
start: '0',
},
{
end: '4',
start: '0',
},
{
end: '5',
start: '1',
},
{
end: '1',
start: '4',
},
{
end: '2',
start: '5',
},
{
end: '3',
start: '5',
},
],
node_types: [
'n8n-nodes-base.executeWorkflowTrigger',
'n8n-nodes-base.set',
'n8n-nodes-base.set',
'n8n-nodes-base.set',
'n8n-nodes-base.debugHelper',
'n8n-nodes-base.switch',
],
nodes: {
'0': {
id: 'a2372c14-87de-42de-9f9e-1c499aa2c279',
items_total: 1,
position: [1000, 240],
runs: 1,
type: 'n8n-nodes-base.executeWorkflowTrigger',
version: 1,
},
'1': {
id: '0f7aa00e-248c-452c-8cd0-62cb55941633',
items_total: 4,
position: [1460, 640],
runs: 2,
type: 'n8n-nodes-base.set',
version: 3.1,
},
'2': {
id: '9165c185-9f1c-4ec1-87bf-76ca66dfae38',
items_total: 4,
position: [1860, 260],
runs: 2,
type: 'n8n-nodes-base.set',
version: 3.4,
},
'3': {
id: '7a915fd5-5987-4ff1-9509-06b24a0a4613',
position: [1940, 680],
type: 'n8n-nodes-base.set',
version: 3.4,
},
'4': {
id: '63050e7c-8ad5-4f44-8fdd-da555e40471b',
items_total: 3,
position: [1220, 240],
runs: 1,
type: 'n8n-nodes-base.debugHelper',
version: 1,
},
'5': {
id: 'fbf7525d-2d1d-4dcf-97a0-43b53d087ef3',
items_total: 4,
position: [1680, 640],
runs: 2,
type: 'n8n-nodes-base.switch',
version: 3.2,
},
},
notes: {},
},
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should handle Evaluation node with undefined metrics - uses default predefined metric', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
operation: 'setMetrics',
// metrics is undefined - should fall back to default metric
},
id: 'eval-node-id',
name: 'Evaluation Node',
type: 'n8n-nodes-base.evaluation',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['n8n-nodes-base.evaluation'],
node_connections: [],
nodes: {
'0': {
id: 'eval-node-id',
type: 'n8n-nodes-base.evaluation',
version: 1,
position: [100, 100],
metric_names: [DEFAULT_EVALUATION_METRIC], // Default metric
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Evaluation Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should handle Evaluation node with custom metric parameter', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
operation: 'setMetrics',
metric: 'helpfulness',
// metrics is undefined but metric parameter is set
},
id: 'eval-node-id',
name: 'Evaluation Node',
type: 'n8n-nodes-base.evaluation',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['n8n-nodes-base.evaluation'],
node_connections: [],
nodes: {
'0': {
id: 'eval-node-id',
type: 'n8n-nodes-base.evaluation',
version: 1,
position: [100, 100],
metric_names: ['helpfulness'], // Custom metric from parameter
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Evaluation Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should handle Evaluation node with valid metrics assignments', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
operation: 'setMetrics',
metrics: {
assignments: [
{ name: 'accuracy', value: 0.95 },
{ name: 'precision', value: 0.87 },
{ name: 'recall', value: 0.92 },
],
},
},
id: 'eval-node-id',
name: 'Evaluation Node',
type: 'n8n-nodes-base.evaluation',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['n8n-nodes-base.evaluation'],
node_connections: [],
nodes: {
'0': {
id: 'eval-node-id',
type: 'n8n-nodes-base.evaluation',
version: 1,
position: [100, 100],
metric_names: ['accuracy', 'precision', 'recall'],
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Evaluation Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test.each([
{ typeVersion: 1.2, parameterValue: undefined, expectedValue: false },
{ typeVersion: 1.3, parameterValue: true, expectedValue: true },
{ typeVersion: 1.3, parameterValue: false, expectedValue: false },
{ typeVersion: 1.3, parameterValue: undefined, expectedValue: true },
])(
'should handle LMChatOpenAi node with use_responses_api set to $expectedValue when typeVersion is $typeVersion and parameterValue is $parameterValue',
({ typeVersion, parameterValue, expectedValue }) => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
responsesApiEnabled: parameterValue,
},
id: 'lmchatopenai-node-id',
name: 'LMChatOpenAi Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment: true })).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.lmChatOpenAi'],
node_connections: [],
nodes: {
'0': {
id: 'lmchatopenai-node-id',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: typeVersion,
position: [100, 100],
use_responses_api: expectedValue,
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'LMChatOpenAi Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
},
);
test('should add package version to node graph', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {},
id: 'fe69383c-e418-4f98-9c0e-924deafa7f93',
name: 'When clicking Execute workflow',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [100, 100],
},
{
parameters: {},
id: 'c5c374f1-6fad-46bb-8eea-ceec126b300a',
name: 'Community Installed Node',
type: 'n8n-nodes-community-installed-node.communityInstalledNode',
typeVersion: 1,
position: [200, 200],
},
{
parameters: {},
id: 'c5c374f1-6fad-46bb-8eea-ceec126b300b',
name: 'Community Installed Node 2',
type: 'n8n-nodes-community-installed-node2.communityInstalledNode',
typeVersion: 1,
position: [300, 300],
},
{
parameters: {
options: {},
},
id: '198133b6-95dd-4f7e-90e5-e16c4cdbad12',
name: 'Community Missing Node',
type: 'community-missing-node.communityMissingNode',
typeVersion: 1,
position: [400, 400],
},
],
};
expect(
generateNodesGraph(workflow, {
...nodeTypes,
getByNameAndVersion: (nodeType: string, version?: number) => {
const orig = nodeTypes.getByNameAndVersion(nodeType, version);
if (nodeType === 'n8n-nodes-community-installed-node.communityInstalledNode') {
return {
...orig,
description: {
...orig.description,
communityNodePackageVersion: '1.0.0',
},
};
}
if (nodeType === 'n8n-nodes-community-installed-node2.communityInstalledNode') {
return {
...orig,
description: {
...orig.description,
communityNodePackageVersion: '1.0.1',
},
};
}
return orig;
},
}),
).toEqual({
evaluationTriggerNodeNames: [],
nameIndices: {
'When clicking Execute workflow': '0',
'Community Installed Node': '1',
'Community Installed Node 2': '2',
'Community Missing Node': '3',
},
webhookNodeNames: [],
nodeGraph: {
is_pinned: false,
node_types: [
'n8n-nodes-base.manualTrigger',
'n8n-nodes-community-installed-node.communityInstalledNode',
'n8n-nodes-community-installed-node2.communityInstalledNode',
'community-missing-node.communityMissingNode',
],
node_connections: [],
nodes: {
'0': {
id: 'fe69383c-e418-4f98-9c0e-924deafa7f93',
type: 'n8n-nodes-base.manualTrigger',
version: 1,
position: [100, 100],
},
'1': {
id: 'c5c374f1-6fad-46bb-8eea-ceec126b300a',
type: 'n8n-nodes-community-installed-node.communityInstalledNode',
version: 1,
position: [200, 200],
package_version: '1.0.0',
},
'2': {
id: 'c5c374f1-6fad-46bb-8eea-ceec126b300b',
type: 'n8n-nodes-community-installed-node2.communityInstalledNode',
version: 1,
position: [300, 300],
package_version: '1.0.1',
},
'3': {
id: '198133b6-95dd-4f7e-90e5-e16c4cdbad12',
type: 'community-missing-node.communityMissingNode',
version: 1,
position: [400, 400],
},
},
notes: {},
},
});
});
test.each(['classify', 'sanitize'])(
'should handle Guardrails node with valid guardrails assignments for operation %s',
(operation) => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
operation,
guardrails: {
promptInjection: {
prompt: 'Custom prompt',
threshold: 0.5,
},
nsfw: {
prompt: 'Custom prompt',
threshold: 0.5,
},
pii: {
type: 'all',
entities: ['email', 'phone'],
customRegex: {
regex: ['/1234567890/'],
},
},
urls: {
allowedUrls: 'https://example.com',
allowedSchemes: ['https'],
blockUserinfo: true,
allowSubdomains: true,
},
},
},
id: 'guardrails-node-id',
name: 'Guardrails Node',
type: '@n8n/n8n-nodes-langchain.guardrails',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.guardrails'],
node_connections: [],
nodes: {
'0': {
id: 'guardrails-node-id',
type: '@n8n/n8n-nodes-langchain.guardrails',
version: 1,
position: [100, 100],
operation,
used_guardrails: ['promptInjection', 'nsfw', 'pii', 'urls'],
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Guardrails Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
},
);
test('should handle Guardrails node without guardrails assignments', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
operation: 'classify',
guardrails: {},
},
id: 'guardrails-node-id',
name: 'Guardrails Node',
type: '@n8n/n8n-nodes-langchain.guardrails',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: ['@n8n/n8n-nodes-langchain.guardrails'],
node_connections: [],
nodes: {
'0': {
id: 'guardrails-node-id',
type: '@n8n/n8n-nodes-langchain.guardrails',
version: 1,
position: [100, 100],
operation: 'classify',
used_guardrails: [],
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'Guardrails Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should handle MCP Client node with authentication method', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
authentication: 'bearerAuth',
},
id: 'mcp-client-node-id',
name: 'MCP Client Node',
type: MCP_CLIENT_NODE_TYPE,
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: [MCP_CLIENT_NODE_TYPE],
node_connections: [],
nodes: {
'0': {
id: 'mcp-client-node-id',
type: MCP_CLIENT_NODE_TYPE,
version: 1,
position: [100, 100],
mcp_client_auth_method: 'bearerAuth',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'MCP Client Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should handle MCP Client node without authentication method (default to none)', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {},
id: 'mcp-client-node-id',
name: 'MCP Client Node',
type: MCP_CLIENT_NODE_TYPE,
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: [MCP_CLIENT_NODE_TYPE],
node_connections: [],
nodes: {
'0': {
id: 'mcp-client-node-id',
type: MCP_CLIENT_NODE_TYPE,
version: 1,
position: [100, 100],
mcp_client_auth_method: 'none',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'MCP Client Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should handle MCP Client Tool node with authentication method', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
authentication: 'bearerAuth',
},
id: 'mcp-client-tool-node-id',
name: 'MCP Client Tool Node',
type: MCP_CLIENT_TOOL_NODE_TYPE,
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: [MCP_CLIENT_TOOL_NODE_TYPE],
node_connections: [],
nodes: {
'0': {
id: 'mcp-client-tool-node-id',
type: MCP_CLIENT_TOOL_NODE_TYPE,
version: 1,
position: [100, 100],
mcp_client_auth_method: 'bearerAuth',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'MCP Client Tool Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
test('should handle MCP Client Tool node without authentication method (default to none)', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {},
id: 'mcp-client-tool-node-id',
name: 'MCP Client Tool Node',
type: MCP_CLIENT_TOOL_NODE_TYPE,
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
nodeGraph: {
node_types: [MCP_CLIENT_TOOL_NODE_TYPE],
node_connections: [],
nodes: {
'0': {
id: 'mcp-client-tool-node-id',
type: MCP_CLIENT_TOOL_NODE_TYPE,
version: 1,
position: [100, 100],
mcp_client_auth_method: 'none',
},
},
notes: {},
is_pinned: false,
},
nameIndices: { 'MCP Client Tool Node': '0' },
webhookNodeNames: [],
evaluationTriggerNodeNames: [],
});
});
describe('ai_model telemetry', () => {
test('should capture ai_model for LM node with plain string model param', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'test-model' },
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_model).toBe('test-model');
});
test('should capture ai_model for LM node with modelName param', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { modelName: 'test-model' },
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_model).toBe('test-model');
});
test('should capture ai_model for LM node with resourceLocator model param', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
model: { __rl: true, mode: 'list', value: 'test-model' },
},
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1.2,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_model).toBe('test-model');
});
test.each([
{
nodeType: '@n8n/n8n-nodes-langchain.openAi',
name: 'OpenAI',
},
{
nodeType: '@n8n/n8n-nodes-langchain.anthropic',
name: 'Anthropic',
},
{
nodeType: '@n8n/n8n-nodes-langchain.ollama',
name: 'Ollama',
},
{
nodeType: '@n8n/n8n-nodes-langchain.googleGemini',
name: 'Google Gemini',
},
])('should capture ai_model for $name vendor node via modelId', ({ nodeType }) => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
modelId: { __rl: true, mode: 'list', value: 'test-model' },
},
id: 'vendor-node-id',
name: 'Vendor Node',
type: nodeType,
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_model).toBe('test-model');
});
test('should not capture ai_model for non-AI node', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {},
id: 'manual-trigger-id',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_model).toBeUndefined();
});
});
describe('ai token usage telemetry', () => {
test('should capture tokens for LM node with tokenUsage in runData', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const runData: IRunData = {
'LM Node': [
{
startTime: 0,
executionTime: 100,
executionIndex: 0,
executionStatus: 'success',
data: {
[NodeConnectionTypes.AiLanguageModel]: [
[
{
json: {
tokenUsage: {
promptTokens: 150,
completionTokens: 80,
totalTokens: 230,
},
},
},
],
],
},
source: [],
},
],
};
const result = generateNodesGraph(workflow, nodeTypes, { runData });
expect(result.nodeGraph.nodes['0'].ai_model).toBe('gpt-4o');
expect(result.nodeGraph.nodes['0'].ai_input_tokens).toBe(150);
expect(result.nodeGraph.nodes['0'].ai_output_tokens).toBe(80);
});
test('should capture tokens from tokenUsageEstimate when tokenUsage is absent', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const runData: IRunData = {
'LM Node': [
{
startTime: 0,
executionTime: 100,
executionIndex: 0,
executionStatus: 'success',
data: {
[NodeConnectionTypes.AiLanguageModel]: [
[
{
json: {
tokenUsageEstimate: {
promptTokens: 200,
completionTokens: 100,
totalTokens: 300,
},
},
},
],
],
},
source: [],
},
],
};
const result = generateNodesGraph(workflow, nodeTypes, { runData });
expect(result.nodeGraph.nodes['0'].ai_input_tokens).toBe(200);
expect(result.nodeGraph.nodes['0'].ai_output_tokens).toBe(100);
});
test('should sum tokens across multiple runs (agent multi-call)', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const makeTaskData = (prompt: number, completion: number) => ({
startTime: 0,
executionTime: 100,
executionIndex: 0,
executionStatus: 'success' as const,
data: {
[NodeConnectionTypes.AiLanguageModel]: [
[
{
json: {
tokenUsage: {
promptTokens: prompt,
completionTokens: completion,
totalTokens: prompt + completion,
},
},
},
],
],
},
source: [],
});
const runData: IRunData = {
'LM Node': [makeTaskData(150, 80), makeTaskData(200, 120), makeTaskData(100, 50)],
};
const result = generateNodesGraph(workflow, nodeTypes, { runData });
expect(result.nodeGraph.nodes['0'].ai_input_tokens).toBe(450);
expect(result.nodeGraph.nodes['0'].ai_output_tokens).toBe(250);
});
test('should not set token fields when no runData is provided', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_model).toBe('gpt-4o');
expect(result.nodeGraph.nodes['0'].ai_input_tokens).toBeUndefined();
expect(result.nodeGraph.nodes['0'].ai_output_tokens).toBeUndefined();
});
test('should not set token fields when runData has no token usage data', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const runData: IRunData = {
'LM Node': [
{
startTime: 0,
executionTime: 100,
executionIndex: 0,
executionStatus: 'success',
data: {
[NodeConnectionTypes.AiLanguageModel]: [
[
{
json: {
response: { generations: [] },
},
},
],
],
},
source: [],
},
],
};
const result = generateNodesGraph(workflow, nodeTypes, { runData });
expect(result.nodeGraph.nodes['0'].ai_model).toBe('gpt-4o');
expect(result.nodeGraph.nodes['0'].ai_input_tokens).toBeUndefined();
expect(result.nodeGraph.nodes['0'].ai_output_tokens).toBeUndefined();
});
test('should capture tokens from task metadata for standalone vendor nodes', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { modelId: { value: 'gpt-4o' } },
id: 'openai-node-id',
name: 'OpenAI',
type: '@n8n/n8n-nodes-langchain.openAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const runData: IRunData = {
OpenAI: [
{
startTime: 0,
executionTime: 100,
executionIndex: 0,
executionStatus: 'success',
data: { main: [[{ json: { content: 'hello' } }]] },
metadata: {
tokenUsage: { inputTokens: 120, outputTokens: 45 },
},
source: [],
},
],
};
const result = generateNodesGraph(workflow, nodeTypes, { runData });
expect(result.nodeGraph.nodes['0'].ai_model).toBe('gpt-4o');
expect(result.nodeGraph.nodes['0'].ai_input_tokens).toBe(120);
expect(result.nodeGraph.nodes['0'].ai_output_tokens).toBe(45);
});
test('should sum metadata tokens across multiple task runs for vendor nodes', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { modelId: { value: 'claude-3-opus' } },
id: 'anthropic-node-id',
name: 'Anthropic',
type: '@n8n/n8n-nodes-langchain.anthropic',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const makeTaskData = (input: number, output: number) => ({
startTime: 0,
executionTime: 100,
executionIndex: 0,
executionStatus: 'success' as const,
data: { main: [[{ json: { content: 'response' } }]] },
metadata: {
tokenUsage: { inputTokens: input, outputTokens: output },
},
source: [],
});
const runData: IRunData = {
Anthropic: [makeTaskData(100, 50), makeTaskData(200, 80)],
};
const result = generateNodesGraph(workflow, nodeTypes, { runData });
expect(result.nodeGraph.nodes['0'].ai_input_tokens).toBe(300);
expect(result.nodeGraph.nodes['0'].ai_output_tokens).toBe(130);
});
test('should prefer ai_languageModel data over metadata when both exist', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'lm-node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const runData: IRunData = {
'LM Node': [
{
startTime: 0,
executionTime: 100,
executionIndex: 0,
executionStatus: 'success',
data: {
[NodeConnectionTypes.AiLanguageModel]: [
[
{
json: {
tokenUsage: {
promptTokens: 150,
completionTokens: 80,
totalTokens: 230,
},
},
},
],
],
},
metadata: {
tokenUsage: { inputTokens: 999, outputTokens: 999 },
},
source: [],
},
],
};
const result = generateNodesGraph(workflow, nodeTypes, { runData });
expect(result.nodeGraph.nodes['0'].ai_input_tokens).toBe(150);
expect(result.nodeGraph.nodes['0'].ai_output_tokens).toBe(80);
});
});
describe('ai_gateway_credentials telemetry', () => {
test('should set ai_gateway_credentials=true when a credential is AI Gateway managed', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
credentials: {
openAiApi: { id: null, name: 'n8n Connect', __aiGatewayManaged: true },
},
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_gateway_credentials).toBe(true);
});
test('should not set ai_gateway_credentials when credentials are not AI Gateway managed', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
credentials: {
openAiApi: { id: 'cred-id', name: 'My OpenAI' },
},
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_gateway_credentials).toBeUndefined();
});
test('should not set ai_gateway_credentials when node has no credentials', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: { model: 'gpt-4o' },
id: 'node-id',
name: 'LM Node',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [100, 100],
},
],
connections: {},
pinData: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0'].ai_gateway_credentials).toBeUndefined();
});
});
});
describe('extractLastExecutedNodeCredentialData', () => {
const cases: Array<[string, IRun]> = [
['no data', mock<IRun>({ data: {} })],
['no executionData', mock<IRun>({ data: { executionData: undefined } })],
[
'no nodeExecutionStack',
mock<IRun>({ data: { executionData: { nodeExecutionStack: undefined } } }),
],
[
'no node',
mock<IRun>({
data: { executionData: { nodeExecutionStack: [{ node: undefined }] } },
}),
],
[
'no credentials',
mock<IRun>({
data: { executionData: { nodeExecutionStack: [{ node: { credentials: undefined } }] } },
}),
],
];
test.each(cases)(
'should return credentialId and credentialsType with null if %s',
(_, runData) => {
expect(extractLastExecutedNodeCredentialData(runData)).toBeNull();
},
);
it('should return correct credentialId and credentialsType when last node executed has credential', () => {
const runData = mock<IRun>({
data: {
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
},
});
expect(extractLastExecutedNodeCredentialData(runData)).toMatchObject(
expect.objectContaining({ credentialId: 'nhu-l8E4hX', credentialType: 'openAiApi' }),
);
});
});
describe('userInInstanceRanOutOfFreeAiCredits', () => {
it('should return false if could not find node credentials', () => {
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: {} } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
parameters: {},
position: [100, 200],
},
{
message: `400 - ${JSON.stringify({
error: {
message: 'error message',
type: 'free_ai_credits_request_error',
code: 200,
},
})}`,
error: {
message: 'error message',
type: 'free_ai_credits_request_error',
code: 200,
},
},
{
httpCode: '400',
},
),
},
},
} as unknown as IRun;
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(false);
});
it('should return false if could not credential type it is not openAiApi', () => {
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: { jiraApi: { id: 'nhu-l8E4hX' } } } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
parameters: {},
position: [100, 200],
},
{
message: `400 - ${JSON.stringify({
error: {
message: 'error message',
type: 'free_ai_credits_request_error',
code: 200,
},
})}`,
error: {
message: 'error message',
type: 'free_ai_credits_request_error',
code: 200,
},
},
{
httpCode: '400',
},
),
},
},
} as unknown as IRun;
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(false);
});
it('should return false if error is not NodeApiError', () => {
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new ExpressionError('error'),
},
},
} as unknown as IRun;
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(false);
});
it('should return false if error is not a free ai credit error', () => {
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
parameters: {},
position: [100, 200],
},
{
message: `400 - ${JSON.stringify({
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
})}`,
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
},
{
httpCode: '400',
},
),
},
},
} as unknown as IRun;
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(false);
});
it('should return true if the user has ran out of free AI credits', () => {
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
parameters: {},
position: [100, 200],
},
{
message: `400 - ${JSON.stringify({
error: {
message: 'error message',
type: 'free_ai_credits_request_error',
code: 400,
},
})}`,
error: {
message: 'error message',
type: 'free_ai_credits_request_error',
code: 400,
},
},
{
httpCode: '400',
},
),
},
},
} as unknown as IRun;
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(true);
});
});
function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();
const firstIdObscured = char.repeat(firstId.length);
const secondIdObscured = char.repeat(secondId.length);
return [
{
full: `https://test.com/api/v1/users/${firstId}`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
];
}
function malformedUrls(idMaker: typeof numericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();
const firstIdObscured = char.repeat(firstId.length);
const secondIdObscured = char.repeat(secondId.length);
return [
{
full: `test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `htp://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
{
full: `test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
];
}
const email = () => encodeURIComponent('test@test.com');
function uuidUrls(
urlsMaker: typeof validUrls | typeof malformedUrls,
baseName = 'test',
namespaceUuid = uuidv4(),
) {
return [
...urlsMaker(() => uuidv5(baseName, namespaceUuid)),
...urlsMaker(uuidv4),
...urlsMaker(() => uuidv3(baseName, namespaceUuid)),
...urlsMaker(uuidv1),
];
}
function numericId(length = randomInt(1, 10)) {
return Array.from({ length }, () => randomInt(10)).join('');
}
function alphanumericId() {
return chooseRandomly([`john${numericId()}`, `title${numericId(1)}`, numericId()]);
}
const chooseRandomly = <T>(array: T[]) => array[randomInt(array.length)];
function generateTestWorkflowAndRunData(): { workflow: Partial<IWorkflowBase>; runData: IRunData } {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {},
id: 'a2372c14-87de-42de-9f9e-1c499aa2c279',
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1,
position: [1000, 240],
},
{
parameters: {
options: {},
},
id: '0f7aa00e-248c-452c-8cd0-62cb55941633',
name: 'Edit Fields',
type: 'n8n-nodes-base.set',
typeVersion: 3.1,
position: [1460, 640],
},
{
parameters: {
options: {},
},
id: '9165c185-9f1c-4ec1-87bf-76ca66dfae38',
name: 'Edit Fields1',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [1860, 260],
},
{
parameters: {
options: {},
},
id: '7a915fd5-5987-4ff1-9509-06b24a0a4613',
name: 'Edit Fields2',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [1940, 680],
},
{
parameters: {
category: 'randomData',
randomDataSeed: '0',
randomDataCount: 3,
},
id: '63050e7c-8ad5-4f44-8fdd-da555e40471b',
name: 'DebugHelper',
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [1220, 240],
},
{
id: 'fbf7525d-2d1d-4dcf-97a0-43b53d087ef3',
name: 'Switch',
type: 'n8n-nodes-base.switch',
typeVersion: 3.2,
position: [1680, 640],
parameters: {},
},
],
connections: {
'Execute Workflow Trigger': {
main: [
[
{
node: 'Edit Fields',
type: 'main' as NodeConnectionType,
index: 0,
},
{
node: 'DebugHelper',
type: 'main' as NodeConnectionType,
index: 0,
},
],
],
},
'Edit Fields': {
main: [
[
{
node: 'Switch',
type: 'main' as NodeConnectionType,
index: 0,
},
],
],
},
DebugHelper: {
main: [
[
{
node: 'Edit Fields',
type: 'main' as NodeConnectionType,
index: 0,
},
],
],
},
Switch: {
main: [
null,
null,
[
{
node: 'Edit Fields1',
type: 'main' as NodeConnectionType,
index: 0,
},
],
[
{
node: 'Edit Fields2',
type: 'main' as NodeConnectionType,
index: 0,
},
],
],
},
},
pinData: {},
};
const runData: IRunData = {
'Execute Workflow Trigger': [
{
hints: [],
startTime: 1727793340927,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'success',
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
},
],
DebugHelper: [
{
hints: [],
startTime: 1727793340928,
executionTime: 0,
executionIndex: 1,
source: [{ previousNode: 'Execute Workflow Trigger' }],
executionStatus: 'success',
data: {
main: [
[
{
json: {
test: 'abc',
},
pairedItem: { item: 0 },
},
{
json: {
test: 'abc',
},
pairedItem: { item: 0 },
},
{
json: {
test: 'abc',
},
pairedItem: { item: 0 },
},
],
],
},
},
],
'Edit Fields': [
{
hints: [],
startTime: 1727793340928,
executionTime: 1,
executionIndex: 2,
source: [{ previousNode: 'DebugHelper' }],
executionStatus: 'success',
data: {
main: [
[
{
json: {
test: 'abc',
},
pairedItem: { item: 0 },
},
{
json: {
test: 'abc',
},
pairedItem: { item: 1 },
},
{
json: {
test: 'abc',
},
pairedItem: { item: 2 },
},
],
],
},
},
{
hints: [],
startTime: 1727793340931,
executionTime: 0,
executionIndex: 3,
source: [{ previousNode: 'Execute Workflow Trigger' }],
executionStatus: 'success',
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
},
],
Switch: [
{
hints: [],
startTime: 1727793340929,
executionTime: 1,
executionIndex: 4,
source: [{ previousNode: 'Edit Fields' }],
executionStatus: 'success',
data: {
main: [
[],
[],
[
{
json: {
test: 'abc',
},
pairedItem: { item: 0 },
},
{
json: {
test: 'abc',
},
pairedItem: { item: 1 },
},
{
json: {
test: 'abc',
},
pairedItem: { item: 2 },
},
],
[],
],
},
},
{
hints: [],
startTime: 1727793340931,
executionTime: 0,
executionIndex: 5,
source: [{ previousNode: 'Edit Fields', previousNodeRun: 1 }],
executionStatus: 'success',
data: { main: [[], [], [{ json: {}, pairedItem: { item: 0 } }], []] },
},
],
'Edit Fields1': [
{
hints: [],
startTime: 1727793340930,
executionTime: 0,
executionIndex: 6,
source: [{ previousNode: 'Switch', previousNodeOutput: 2 }],
executionStatus: 'success',
data: {
main: [
[
{ json: {}, pairedItem: { item: 0 } },
{ json: {}, pairedItem: { item: 1 } },
{ json: {}, pairedItem: { item: 2 } },
],
],
},
},
{
hints: [],
startTime: 1727793340932,
executionTime: 1,
executionIndex: 7,
source: [{ previousNode: 'Switch', previousNodeOutput: 2, previousNodeRun: 1 }],
executionStatus: 'success',
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
},
],
};
return { workflow, runData };
}
describe('makeAIMetrics', () => {
const makeNode = (parameters: object, type: string) =>
({
parameters,
type,
typeVersion: 2.1,
id: '7cb0b373-715c-4a89-8bbb-3f238907bc86',
name: 'a name',
position: [0, 0],
}) as INode;
it('should count applicable nodes and parameters', () => {
const nodes = [
makeNode(
{
sendTo: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}",
sendTwo: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}",
subject: "={{ $fromAI('Subject', ``, 'string') }}",
},
'n8n-nodes-base.gmailTool',
),
makeNode(
{
subject: "={{ $fromAI('Subject', ``, 'string') }}",
verb: "={{ $fromAI('Verb', ``, 'string') }}",
},
'n8n-nodes-base.gmailTool',
),
makeNode(
{
subject: "'A Subject'",
},
'n8n-nodes-base.gmailTool',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['AI'],
subcategories: { AI: ['Tools'] },
},
} as unknown as INodeTypeDescription,
}),
});
const result = resolveAIMetrics(nodes, nodeTypes);
expect(result).toMatchObject({
aiNodeCount: 3,
aiToolCount: 3,
fromAIOverrideCount: 2,
fromAIExpressionCount: 3,
});
});
it('should not count non-applicable nodes and parameters', () => {
const nodes = [
makeNode(
{
sendTo: 'someone',
},
'n8n-nodes-base.gmail',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {} as unknown as INodeTypeDescription,
}),
});
const result = resolveAIMetrics(nodes, nodeTypes);
expect(result).toMatchObject({});
});
it('should count ai nodes without tools', () => {
const nodes = [
makeNode(
{
sendTo: 'someone',
},
'n8n-nodes-base.gmailTool',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['AI'],
},
} as unknown as INodeTypeDescription,
}),
});
const result = resolveAIMetrics(nodes, nodeTypes);
expect(result).toMatchObject({
aiNodeCount: 1,
aiToolCount: 0,
fromAIOverrideCount: 0,
fromAIExpressionCount: 0,
});
});
});
describe('resolveVectorStoreMetrics', () => {
const makeNode = (parameters: object, type: string) =>
({
parameters,
type,
typeVersion: 1,
id: '7cb0b373-715c-4a89-8bbb-3f238907bc86',
name: 'a name',
position: [0, 0],
}) as INode;
it('should return empty object if no vector store nodes are present', () => {
const nodes = [
makeNode(
{
mode: 'insert',
},
'n8n-nodes-base.nonVectorStoreNode',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['Non-AI'],
},
} as unknown as INodeTypeDescription,
}),
});
const run = mock<IRun>({
data: {
resultData: {
runData: {},
},
},
});
const result = resolveVectorStoreMetrics(nodes, nodeTypes, run);
expect(result).toMatchObject({});
});
it('should detect vector store nodes that inserted data', () => {
const nodes = [
makeNode(
{
mode: 'insert',
},
'n8n-nodes-base.vectorStoreNode',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['AI'],
subcategories: { AI: ['Vector Stores'] },
},
} as unknown as INodeTypeDescription,
}),
});
const run = mock<IRun>({
data: {
resultData: {
runData: {
'a name': [
{
executionStatus: 'success',
},
],
},
},
},
});
const result = resolveVectorStoreMetrics(nodes, nodeTypes, run);
expect(result).toMatchObject({
insertedIntoVectorStore: true,
queriedDataFromVectorStore: false,
});
});
it('should detect vector store nodes that queried data', () => {
const nodes = [
makeNode(
{
mode: 'retrieve',
},
'n8n-nodes-base.vectorStoreNode',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['AI'],
subcategories: { AI: ['Vector Stores'] },
},
} as unknown as INodeTypeDescription,
}),
});
const run = mock<IRun>({
data: {
resultData: {
runData: {
'a name': [
{
executionStatus: 'success',
},
],
},
},
},
});
const result = resolveVectorStoreMetrics(nodes, nodeTypes, run);
expect(result).toMatchObject({
insertedIntoVectorStore: false,
queriedDataFromVectorStore: true,
});
});
it('should detect vector store nodes that both inserted and queried data', () => {
const nodes = [
makeNode(
{
mode: 'insert',
},
'n8n-nodes-base.vectorStoreNode',
),
makeNode(
{
mode: 'retrieve',
},
'n8n-nodes-base.vectorStoreNode',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['AI'],
subcategories: { AI: ['Vector Stores'] },
},
} as unknown as INodeTypeDescription,
}),
});
const run = mock<IRun>({
data: {
resultData: {
runData: {
'a name': [
{
executionStatus: 'success',
},
],
},
},
},
});
const result = resolveVectorStoreMetrics(nodes, nodeTypes, run);
expect(result).toMatchObject({
insertedIntoVectorStore: true,
queriedDataFromVectorStore: true,
});
});
it('should return empty object if no successful executions are found', () => {
const nodes = [
makeNode(
{
mode: 'insert',
},
'n8n-nodes-base.vectorStoreNode',
),
];
const nodeTypes = mock<NodeTypes>({
getByNameAndVersion: () => ({
description: {
codex: {
categories: ['AI'],
subcategories: { AI: ['Vector Stores'] },
},
} as unknown as INodeTypeDescription,
}),
});
const run = mock<IRun>({
data: {
resultData: {
runData: {
'a name': [
{
executionStatus: 'error',
},
],
},
},
},
});
const result = resolveVectorStoreMetrics(nodes, nodeTypes, run);
expect(result).toMatchObject({
insertedIntoVectorStore: false,
queriedDataFromVectorStore: false,
});
});
});
describe('extractLastExecutedNodeStructuredOutputErrorInfo', () => {
const mockWorkflow = (nodes: INode[], connections?: any): IWorkflowBase => ({
createdAt: new Date(),
updatedAt: new Date(),
id: 'test-workflow',
name: 'Test Workflow',
active: false,
activeVersionId: null,
isArchived: false,
nodes,
connections: connections || {},
settings: {},
pinData: {},
versionId: 'test-version',
});
const mockAgentNode = (name = 'Agent', hasOutputParser = true): INode => ({
id: 'agent-node-id',
name,
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 1,
position: [100, 100],
parameters: {
hasOutputParser,
},
});
const mockLanguageModelNode = (name = 'Model', model = 'gpt-4'): INode => ({
id: 'model-node-id',
name,
type: 'n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [200, 200],
parameters: {
model,
},
});
const mockToolNode = (name: string): INode => ({
id: `tool-${name}`,
name,
type: 'n8n-nodes-base.httpRequestTool',
typeVersion: 1,
position: [300, 300],
parameters: {},
});
const mockRunData = (lastNodeExecuted: string, error?: any, nodeRunData?: any): IRun => ({
mode: 'manual',
status: error ? 'error' : 'success',
startedAt: new Date(),
stoppedAt: new Date(),
storedAt: 'db',
data: {
startData: {},
resultData: {
lastNodeExecuted,
error,
runData: nodeRunData || {},
},
} as any,
});
it('should return empty object when there is no error', () => {
const workflow = mockWorkflow([mockAgentNode()]);
const runData = mockRunData('Agent');
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({});
});
it('should return empty object when lastNodeExecuted is not defined', () => {
const workflow = mockWorkflow([mockAgentNode()]);
const runData = mockRunData('', new Error('Some error'));
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({});
});
it('should return empty object when last executed node is not found in workflow', () => {
const workflow = mockWorkflow([mockAgentNode('Agent')]);
const runData = mockRunData('NonExistentNode', new Error('Some error'));
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({});
});
it('should return empty object when last executed node is not an agent node', () => {
const workflow = mockWorkflow([mockLanguageModelNode('Model')]);
const runData = mockRunData('Model', new Error('Some error'));
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({});
});
it('should return empty object when agent node does not have output parser', () => {
const workflow = mockWorkflow([mockAgentNode('Agent', false)]);
const runData = mockRunData('Agent', new Error('Some error'));
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({});
});
it('should return error info without output parser fail reason when error is not output parser error', () => {
const workflow = mockWorkflow([mockAgentNode()]);
const runData = mockRunData('Agent', new Error('Different error'), {
Agent: [
{
error: {
message: 'Some other error',
},
},
],
});
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({
num_tools: 0,
});
});
it('should return error info with output parser fail reason', () => {
const workflow = mockWorkflow([mockAgentNode()]);
const runData = mockRunData('Agent', new Error('Some error'), {
Agent: [
{
error: {
message: "Model output doesn't fit required format",
context: {
outputParserFailReason: 'Failed to parse JSON output',
},
},
},
],
});
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({
output_parser_fail_reason: 'Failed to parse JSON output',
num_tools: 0,
});
});
it('should count connected tools correctly', () => {
const agentNode = mockAgentNode();
const tool1 = mockToolNode('Tool1');
const tool2 = mockToolNode('Tool2');
const workflow = mockWorkflow([agentNode, tool1, tool2], {
Tool1: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
Tool2: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
});
const runData = mockRunData('Agent', new Error('Some error'));
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({
num_tools: 2,
});
});
it('should extract model name from connected language model node', () => {
const agentNode = mockAgentNode();
const modelNode = mockLanguageModelNode('OpenAI Model', 'gpt-4-turbo');
const workflow = mockWorkflow([agentNode, modelNode], {
'OpenAI Model': {
[NodeConnectionTypes.AiLanguageModel]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiLanguageModel, index: 0 }],
],
},
});
const runData = mockRunData('Agent', new Error('Some error'));
vi.mocked(nodeHelpers.getNodeParameters).mockReturnValueOnce(
mock<INodeParameters>({ model: { value: 'gpt-4-turbo' } }),
);
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({
num_tools: 0,
model_name: 'gpt-4-turbo',
});
});
it('should handle complete scenario with tools, model, and output parser error', () => {
const agentNode = mockAgentNode();
const modelNode = mockLanguageModelNode('OpenAI Model', 'gpt-4');
const tool1 = mockToolNode('HTTPTool');
const tool2 = mockToolNode('SlackTool');
const tool3 = mockToolNode('GoogleSheetsTool');
const workflow = mockWorkflow([agentNode, modelNode, tool1, tool2, tool3], {
'OpenAI Model': {
[NodeConnectionTypes.AiLanguageModel]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiLanguageModel, index: 0 }],
],
},
HTTPTool: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
SlackTool: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
GoogleSheetsTool: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
});
const runData = mockRunData('Agent', new Error('Workflow error'), {
Agent: [
{
error: {
message: "Model output doesn't fit required format",
context: {
outputParserFailReason: 'Invalid JSON structure: Expected object, got string',
},
},
},
],
});
vi.mocked(nodeHelpers.getNodeParameters).mockReturnValueOnce(
mock<INodeParameters>({ model: { value: 'gpt-4.1-mini' } }),
);
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({
output_parser_fail_reason: 'Invalid JSON structure: Expected object, got string',
num_tools: 3,
model_name: 'gpt-4.1-mini',
});
});
it('should pick correct model when workflow has multiple model nodes but only one connected to agent', () => {
const agentNode = mockAgentNode();
const connectedModel = mockLanguageModelNode('Connected Model', 'gpt-4');
const unconnectedModel = mockLanguageModelNode('Unconnected Model', 'claude-3');
const workflow = mockWorkflow([agentNode, connectedModel, unconnectedModel], {
'Connected Model': {
[NodeConnectionTypes.AiLanguageModel]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiLanguageModel, index: 0 }],
],
},
// Unconnected Model is not connected to anything
});
const runData = mockRunData('Agent', new Error('Some error'));
vi.mocked(nodeHelpers.getNodeParameters).mockReturnValueOnce(
mock<INodeParameters>({ model: 'gpt-4' }),
);
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({
num_tools: 0,
model_name: 'gpt-4',
});
});
it('should only count tools connected to the agent when workflow has multiple tool nodes', () => {
const agentNode = mockAgentNode();
const connectedTool1 = mockToolNode('ConnectedTool1');
const connectedTool2 = mockToolNode('ConnectedTool2');
const unconnectedTool1 = mockToolNode('UnconnectedTool1');
const unconnectedTool2 = mockToolNode('UnconnectedTool2');
const someOtherNode: INode = {
id: 'other-node',
name: 'SomeOtherNode',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [400, 400],
parameters: {},
};
const workflow = mockWorkflow(
[
agentNode,
connectedTool1,
connectedTool2,
unconnectedTool1,
unconnectedTool2,
someOtherNode,
],
{
ConnectedTool1: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
ConnectedTool2: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
// UnconnectedTool1 and UnconnectedTool2 are connected to SomeOtherNode, not to Agent
UnconnectedTool1: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'SomeOtherNode', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
UnconnectedTool2: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'SomeOtherNode', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
},
);
const runData = mockRunData('Agent', new Error('Some error'));
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({
num_tools: 2, // Only ConnectedTool1 and ConnectedTool2
});
});
it('should extract model name from modelName parameter when model parameter is not present', () => {
const agentNode = mockAgentNode();
const modelNode: INode = {
id: 'model-node-id',
name: 'Google Gemini Model',
type: 'n8n-nodes-langchain.lmChatGoogleGemini',
typeVersion: 1,
position: [200, 200],
parameters: {
// Using modelName instead of model
modelName: 'gemini-1.5-pro',
},
};
const workflow = mockWorkflow([agentNode, modelNode], {
'Google Gemini Model': {
[NodeConnectionTypes.AiLanguageModel]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiLanguageModel, index: 0 }],
],
},
});
const runData = mockRunData('Agent', new Error('Some error'));
vi.mocked(nodeHelpers.getNodeParameters).mockReturnValueOnce(
mock<INodeParameters>({ modelName: 'gemini-1.5-pro' }),
);
const result = extractLastExecutedNodeStructuredOutputErrorInfo(workflow, nodeTypes, runData);
expect(result).toEqual({
num_tools: 0,
model_name: 'gemini-1.5-pro',
});
});
it('should capture Agent node streaming parameters', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
agent: 'toolsAgent',
options: {
enableStreaming: false,
},
},
id: 'agent-id-streaming-disabled',
name: 'Agent with streaming disabled',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2.1,
position: [100, 100],
},
{
parameters: {
agent: 'conversationalAgent',
options: {
enableStreaming: true,
},
},
id: 'agent-id-streaming-enabled',
name: 'Agent with streaming enabled',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2.1,
position: [300, 100],
},
{
parameters: {
agent: 'openAiFunctionsAgent',
},
id: 'agent-id-default-streaming',
name: 'Agent with default streaming',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2.1,
position: [500, 100],
},
],
connections: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0']).toEqual({
id: 'agent-id-streaming-disabled',
type: '@n8n/n8n-nodes-langchain.agent',
version: 2.1,
position: [100, 100],
agent: 'toolsAgent',
is_streaming: false,
});
expect(result.nodeGraph.nodes['1']).toEqual({
id: 'agent-id-streaming-enabled',
type: '@n8n/n8n-nodes-langchain.agent',
version: 2.1,
position: [300, 100],
agent: 'conversationalAgent',
is_streaming: true,
});
expect(result.nodeGraph.nodes['2']).toEqual({
id: 'agent-id-default-streaming',
type: '@n8n/n8n-nodes-langchain.agent',
version: 2.1,
position: [500, 100],
agent: 'openAiFunctionsAgent',
is_streaming: true,
});
});
it('should capture Chat Trigger node streaming parameters', () => {
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
public: true,
options: {
responseMode: 'streaming',
},
},
id: 'chat-trigger-id',
name: 'Chat Trigger',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1,
position: [100, 100],
},
{
parameters: {
public: false,
options: {
responseMode: 'lastNode',
},
},
id: 'chat-trigger-id-2',
name: 'Chat Trigger 2',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1,
position: [300, 100],
},
],
connections: {},
};
const result = generateNodesGraph(workflow, nodeTypes);
expect(result.nodeGraph.nodes['0']).toEqual({
id: 'chat-trigger-id',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
version: 1,
position: [100, 100],
response_mode: 'streaming',
public_chat: true,
});
expect(result.nodeGraph.nodes['1']).toEqual({
id: 'chat-trigger-id-2',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
version: 1,
position: [300, 100],
response_mode: 'lastNode',
public_chat: false,
});
});
});
describe('getNodeRole', () => {
const makeNode = (name: string, type: string, typeVersion = 1): INode => ({
id: `${name}-id`,
name,
type,
typeVersion,
position: [0, 0],
parameters: {},
});
describe('trigger role', () => {
it('should return trigger for node with no incoming main connections', () => {
const nodes = [
makeNode('Trigger', 'n8n-nodes-base.manualTrigger'),
makeNode('Set', 'n8n-nodes-base.set'),
];
const connections: IConnections = {
Trigger: {
[NodeConnectionTypes.Main]: [[{ node: 'Set', type: NodeConnectionTypes.Main, index: 0 }]],
},
};
const result = getNodeRole('Trigger', connections, nodeTypes, nodes);
expect(result).toBe('trigger');
});
it('should return trigger for isolated node with no connections', () => {
const nodes = [makeNode('Trigger', 'n8n-nodes-base.manualTrigger')];
const connections: IConnections = {};
const result = getNodeRole('Trigger', connections, nodeTypes, nodes);
expect(result).toBe('trigger');
});
});
describe('terminal role', () => {
it('should return terminal for node with incoming but no outgoing connections', () => {
const nodes = [
makeNode('Trigger', 'n8n-nodes-base.manualTrigger'),
makeNode('Set', 'n8n-nodes-base.set'),
];
const connections: IConnections = {
Trigger: {
[NodeConnectionTypes.Main]: [[{ node: 'Set', type: NodeConnectionTypes.Main, index: 0 }]],
},
};
const result = getNodeRole('Set', connections, nodeTypes, nodes);
expect(result).toBe('terminal');
});
});
describe('internal role', () => {
it('should return internal for node with both incoming and outgoing connections', () => {
const nodes = [
makeNode('Trigger', 'n8n-nodes-base.manualTrigger'),
makeNode('Middle', 'n8n-nodes-base.set'),
makeNode('End', 'n8n-nodes-base.set'),
];
const connections: IConnections = {
Trigger: {
[NodeConnectionTypes.Main]: [
[{ node: 'Middle', type: NodeConnectionTypes.Main, index: 0 }],
],
},
Middle: {
[NodeConnectionTypes.Main]: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]],
},
};
const result = getNodeRole('Middle', connections, nodeTypes, nodes);
expect(result).toBe('internal');
});
it('should return internal for subnode even without main connections', () => {
const nodes = [
makeNode('Agent', '@n8n/n8n-nodes-langchain.agent'),
makeNode('Wikipedia', '@n8n/n8n-nodes-langchain.toolWikipedia'),
];
const connections: IConnections = {
Wikipedia: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
};
const result = getNodeRole('Wikipedia', connections, nodeTypes, nodes);
expect(result).toBe('internal');
});
it('should return internal for calculator tool subnode', () => {
const nodes = [
makeNode('Agent', '@n8n/n8n-nodes-langchain.agent'),
makeNode('Calculator', '@n8n/n8n-nodes-langchain.toolCalculator'),
];
const connections: IConnections = {
Calculator: {
[NodeConnectionTypes.AiTool]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }],
],
},
};
const result = getNodeRole('Calculator', connections, nodeTypes, nodes);
expect(result).toBe('internal');
});
it('should return internal for PartialExecutionToolExecutor', () => {
const nodes: INode[] = [];
const connections: IConnections = {};
const result = getNodeRole('PartialExecutionToolExecutor', connections, nodeTypes, nodes);
expect(result).toBe('internal');
});
});
describe('edge cases', () => {
it('should return trigger for node not in connections', () => {
const nodes = [makeNode('Isolated', 'n8n-nodes-base.set')];
const connections: IConnections = {};
const result = getNodeRole('Isolated', connections, nodeTypes, nodes);
expect(result).toBe('trigger');
});
it('should handle node with empty output arrays', () => {
const nodes = [
makeNode('Trigger', 'n8n-nodes-base.manualTrigger'),
makeNode('Set', 'n8n-nodes-base.set'),
];
const connections: IConnections = {
Trigger: {
[NodeConnectionTypes.Main]: [[{ node: 'Set', type: NodeConnectionTypes.Main, index: 0 }]],
},
Set: {
[NodeConnectionTypes.Main]: [[]],
},
};
const result = getNodeRole('Set', connections, nodeTypes, nodes);
expect(result).toBe('terminal');
});
it('should handle multiple output branches', () => {
const nodes = [
makeNode('Trigger', 'n8n-nodes-base.manualTrigger'),
makeNode('Set1', 'n8n-nodes-base.set'),
makeNode('TrueBranch', 'n8n-nodes-base.set'),
makeNode('FalseBranch', 'n8n-nodes-base.set'),
];
const connections: IConnections = {
Trigger: {
[NodeConnectionTypes.Main]: [
[{ node: 'Set1', type: NodeConnectionTypes.Main, index: 0 }],
],
},
Set1: {
[NodeConnectionTypes.Main]: [
[{ node: 'TrueBranch', type: NodeConnectionTypes.Main, index: 0 }],
[{ node: 'FalseBranch', type: NodeConnectionTypes.Main, index: 0 }],
],
},
};
const result = getNodeRole('Set1', connections, nodeTypes, nodes);
expect(result).toBe('internal');
});
it('should handle node not found in nodes array', () => {
const nodes = [makeNode('Trigger', 'n8n-nodes-base.manualTrigger')];
const connections: IConnections = {};
// Node 'NotInArray' is not in the nodes array
const result = getNodeRole('NotInArray', connections, nodeTypes, nodes);
expect(result).toBe('trigger');
});
});
});