Files
n8n-enterprise-unlocked/packages/workflow/test/telemetry-helpers.test.ts

2391 lines
58 KiB
TypeScript
Raw 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 { STICKY_NODE_TYPE } from '../src/constants';
import { ApplicationError, ExpressionError, NodeApiError } from '../src/errors';
import type {
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,
resolveAIMetrics,
resolveVectorStoreMetrics,
userInInstanceRanOutOfFreeAiCredits,
} from '../src/telemetry-helpers';
import { randomInt } from '../src/utils';
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);
}
});
});
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,
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,
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,
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,
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/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 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,
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/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],
},
},
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',
domain_path: '/path/test',
},
},
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',
domain_path: '/path/test',
},
},
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: '',
domain_path: '',
},
},
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',
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 }] });
vi.spyOn(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: [],
});
});
});
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: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'https://test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'https://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: 'htp://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', async () => {
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', async () => {
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', async () => {
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,
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(),
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.spyOn(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.spyOn(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.spyOn(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.spyOn(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',
});
});
});