From 570d1e7aadd5ba1d77ab95432bed0c9fcbe812a5 Mon Sep 17 00:00:00 2001 From: Dana <152518854+dana-gill@users.noreply.github.com> Date: Fri, 16 May 2025 11:16:00 +0200 Subject: [PATCH] feat(n8n Evaluation Trigger Node): Add Evaluation Trigger and Evaluation Node (#15194) --- cypress/e2e/20-workflow-executions.cy.ts | 2 +- .../Node/NodeCreator/NodesListPanel.test.ts | 2 +- .../__snapshots__/viewsData.spec.ts.snap | 60 ++++ .../composables/useActionsGeneration.ts | 20 +- .../NodeCreator/useActionsGeneration.test.ts | 59 ++++ .../Node/NodeCreator/viewsData.spec.ts | 15 + .../components/Node/NodeCreator/viewsData.ts | 27 +- packages/frontend/editor-ui/src/constants.ts | 7 + .../Evaluation/Evaluation/Description.node.ts | 119 +++++++ .../Evaluation/Evaluation.node.ee.json | 15 + .../Evaluation/Evaluation.node.ee.ts | 101 ++++++ .../EvaluationTrigger.node.ee.json | 15 + .../EvaluationTrigger.node.ee.ts | 198 +++++++++++ .../nodes/Evaluation/methods/index.ts | 2 + .../nodes/Evaluation/methods/loadOptions.ts | 17 + .../Evaluation/test/Evaluation.node.test.ts | 319 +++++++++++++++++ .../test/EvaluationTrigger.node.test.ts | 321 ++++++++++++++++++ .../test/evaluationTriggerUtils.test.ts | 101 ++++++ .../nodes/Evaluation/test/loadOptions.test.ts | 63 ++++ .../utils/evaluationTriggerUtils.ts | 131 +++++++ .../nodes/Evaluation/utils/evaluationUtils.ts | 182 ++++++++++ packages/nodes-base/package.json | 2 + packages/workflow/src/Constants.ts | 2 + .../workflow/src/errors/base/base.error.ts | 4 +- 24 files changed, 1778 insertions(+), 6 deletions(-) create mode 100644 packages/nodes-base/nodes/Evaluation/Evaluation/Description.node.ts create mode 100644 packages/nodes-base/nodes/Evaluation/Evaluation/Evaluation.node.ee.json create mode 100644 packages/nodes-base/nodes/Evaluation/Evaluation/Evaluation.node.ee.ts create mode 100644 packages/nodes-base/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.json create mode 100644 packages/nodes-base/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.ts create mode 100644 packages/nodes-base/nodes/Evaluation/methods/index.ts create mode 100644 packages/nodes-base/nodes/Evaluation/methods/loadOptions.ts create mode 100644 packages/nodes-base/nodes/Evaluation/test/Evaluation.node.test.ts create mode 100644 packages/nodes-base/nodes/Evaluation/test/EvaluationTrigger.node.test.ts create mode 100644 packages/nodes-base/nodes/Evaluation/test/evaluationTriggerUtils.test.ts create mode 100644 packages/nodes-base/nodes/Evaluation/test/loadOptions.test.ts create mode 100644 packages/nodes-base/nodes/Evaluation/utils/evaluationTriggerUtils.ts create mode 100644 packages/nodes-base/nodes/Evaluation/utils/evaluationUtils.ts diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index 618334e2b6..eb0129ba33 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -275,7 +275,7 @@ describe('Workflow Executions', () => { cy.getByTestId('node-creator-item-name') .should('be.visible') - .filter(':contains("Trigger")') + .filter(':contains("Trigger manually")') .click(); executionsTab.actions.switchToExecutionsTab(); executionsTab.getters.executionsSidebar().should('be.visible'); diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/NodesListPanel.test.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/NodesListPanel.test.ts index 4bd7e98baf..29d474b556 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/NodesListPanel.test.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/NodesListPanel.test.ts @@ -76,7 +76,7 @@ describe('NodesListPanel', () => { await fireEvent.click(container.querySelector('.backButton')!); await nextTick(); - expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8); + expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9); }); it('should render regular nodes', async () => { diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/__snapshots__/viewsData.spec.ts.snap b/packages/frontend/editor-ui/src/components/Node/NodeCreator/__snapshots__/viewsData.spec.ts.snap index f5336a7432..3bb9d4970e 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/__snapshots__/viewsData.spec.ts.snap +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/__snapshots__/viewsData.spec.ts.snap @@ -132,3 +132,63 @@ exports[`viewsData > AIView > should return ai view without ai transform node if "value": "AI", } `; + +exports[`viewsData > AIView > should return ai view without ai transform node if ask ai is not enabled and node is not in the list 1`] = ` +{ + "items": [ + { + "key": "ai_templates_root", + "properties": { + "description": "See what's possible and get started 5x faster", + "icon": "box-open", + "name": "ai_templates_root", + "tag": { + "text": "Recommended", + "type": "info", + }, + "title": "AI Templates", + "url": "template-repository-url.n8n.io?test=value&utm_user_role=AdvancedAI", + }, + "type": "link", + }, + { + "key": "agent", + "properties": { + "description": "example mock agent node", + "displayName": "agent", + "group": [], + "icon": "fa:pen", + "iconUrl": "nodes/test-node/icon.svg", + "name": "agent", + "title": "agent", + }, + "type": "node", + }, + { + "key": "chain", + "properties": { + "description": "example mock chain node", + "displayName": "chain", + "group": [], + "icon": "fa:pen", + "iconUrl": "nodes/test-node/icon.svg", + "name": "chain", + "title": "chain", + }, + "type": "node", + }, + { + "key": "AI Other", + "properties": { + "description": "Embeddings, Vector Stores, LLMs and other AI nodes", + "icon": "robot", + "title": "Other AI Nodes", + }, + "type": "view", + }, + ], + "subtitle": "Select an AI Node to add to your workflow", + "title": "AI Nodes", + "value": "AI", +} +`; diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts index 9e87c4861e..d28f702748 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts @@ -4,6 +4,7 @@ import { AI_CATEGORY_TOOLS, AI_SUBCATEGORY, CUSTOM_API_CALL_KEY, + EVALUATION_TRIGGER, HTTP_REQUEST_NODE_TYPE, } from '@/constants'; import { memoize, startCase } from 'lodash-es'; @@ -19,6 +20,7 @@ import { i18n } from '@/plugins/i18n'; import { getCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes'; import { formatTriggerActionName } from '../utils'; +import { usePostHog } from '@/stores/posthog.store'; const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended'; @@ -330,7 +332,23 @@ export function useActionsGenerator() { nodeTypes: INodeTypeDescription[], httpOnlyCredentials: ICredentialType[], ) { - const visibleNodeTypes = [...nodeTypes]; + const posthogStore = usePostHog(); + + const isEvaluationVariantEnabled = posthogStore.isVariantEnabled( + EVALUATION_TRIGGER.name, + EVALUATION_TRIGGER.variant, + ); + + const visibleNodeTypes = nodeTypes.filter((node) => { + if (isEvaluationVariantEnabled) { + return true; + } + return ( + node.name !== 'n8n-nodes-base.evaluation' && + node.name !== 'n8n-nodes-base.evaluationTrigger' + ); + }); + const actions: ActionsRecord = {}; const mergedNodes: SimplifiedNodeType[] = []; visibleNodeTypes diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/useActionsGeneration.test.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/useActionsGeneration.test.ts index 63db6efc22..39cbe23ad3 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/useActionsGeneration.test.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/useActionsGeneration.test.ts @@ -1,5 +1,10 @@ import { NodeConnectionTypes, type INodeProperties, type INodeTypeDescription } from 'n8n-workflow'; import { useActionsGenerator } from './composables/useActionsGeneration'; +import { usePostHog } from '@/stores/posthog.store'; +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; + +let posthogStore: ReturnType; describe('useActionsGenerator', () => { const { generateMergedNodesAndActions } = useActionsGenerator(); @@ -19,6 +24,17 @@ describe('useActionsGenerator', () => { properties: [], }; + beforeEach(() => { + vi.clearAllMocks(); + + const pinia = createTestingPinia({ stubActions: false }); + setActivePinia(pinia); + + posthogStore = usePostHog(); + + vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(true); + }); + describe('App actions for resource category', () => { const resourcePropertyWithUser: INodeProperties = { displayName: 'Resource', @@ -386,5 +402,48 @@ describe('useActionsGenerator', () => { ], }); }); + + it('should not return evaluation or evaluation trigger node if variant is not enabled', () => { + vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(false); + + const node: INodeTypeDescription = { + ...baseV2NodeWoProps, + properties: [ + resourcePropertyWithUser, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: {}, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get description', + }, + ], + default: 'get', + }, + ], + }; + + const evalNode: INodeTypeDescription = { + ...baseV2NodeWoProps, + name: 'n8n-nodes-base.evaluation', + }; + + const evalNodeTrigger: INodeTypeDescription = { + ...baseV2NodeWoProps, + name: 'n8n-nodes-base.evaluationTrigger', + }; + + const { mergedNodes } = generateMergedNodesAndActions([node, evalNode, evalNodeTrigger], []); + + mergedNodes.forEach((mergedNode) => { + expect(mergedNode.name).not.toEqual('n8n-nodes-base.evaluation'); + expect(mergedNode.name).not.toEqual('n8n-nodes-base.evaluationTrigger'); + }); + }); }); }); diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/viewsData.spec.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/viewsData.spec.ts index 28327ca5df..16ce026619 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/viewsData.spec.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/viewsData.spec.ts @@ -7,6 +7,9 @@ import { useSettingsStore } from '@/stores/settings.store'; import { AIView } from './viewsData'; import { mockNodeTypeDescription } from '@/__tests__/mocks'; import { useTemplatesStore } from '@/stores/templates.store'; +import { usePostHog } from '@/stores/posthog.store'; + +let posthogStore: ReturnType; const getNodeType = vi.fn(); @@ -51,6 +54,9 @@ describe('viewsData', () => { beforeAll(() => { setActivePinia(createTestingPinia()); + posthogStore = usePostHog(); + vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(true); + const templatesStore = useTemplatesStore(); vi.spyOn(templatesStore, 'websiteTemplateRepositoryParameters', 'get').mockImplementation( @@ -86,5 +92,14 @@ describe('viewsData', () => { expect(AIView([])).toMatchSnapshot(); }); + + test('should return ai view without ai transform node if ask ai is not enabled and node is not in the list', () => { + vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(false); + + const settingsStore = useSettingsStore(); + vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(false); + + expect(AIView([])).toMatchSnapshot(); + }); }); }); diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/viewsData.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/viewsData.ts index 2920687651..e980933693 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/viewsData.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/viewsData.ts @@ -57,17 +57,19 @@ import { AI_CODE_TOOL_LANGCHAIN_NODE_TYPE, AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, HUMAN_IN_THE_LOOP_CATEGORY, + EVALUATION_TRIGGER, } from '@/constants'; import { useI18n } from '@/composables/useI18n'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import type { SimplifiedNodeType } from '@/Interface'; import type { INodeTypeDescription, Themed } from 'n8n-workflow'; -import { NodeConnectionTypes } from 'n8n-workflow'; +import { EVALUATION_TRIGGER_NODE_TYPE, NodeConnectionTypes } from 'n8n-workflow'; import type { NodeConnectionType } from 'n8n-workflow'; import { useTemplatesStore } from '@/stores/templates.store'; import type { BaseTextKey } from '@/plugins/i18n'; import { camelCase } from 'lodash-es'; import { useSettingsStore } from '@/stores/settings.store'; +import { usePostHog } from '@/stores/posthog.store'; export interface NodeViewItemSection { key: string; @@ -141,6 +143,16 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView { const i18n = useI18n(); const nodeTypesStore = useNodeTypesStore(); const templatesStore = useTemplatesStore(); + const posthogStore = usePostHog(); + + const isEvaluationVariantEnabled = posthogStore.isVariantEnabled( + EVALUATION_TRIGGER.name, + EVALUATION_TRIGGER.variant, + ); + + const evaluationNodeStore = nodeTypesStore.getNodeType('n8n-nodes-base.evaluation'); + const evaluationNode = + isEvaluationVariantEnabled && evaluationNodeStore ? [getNodeView(evaluationNodeStore)] : []; const chainNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_CHAINS); const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS); @@ -177,6 +189,7 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView { ...agentNodes, ...chainNodes, ...transformNode, + ...evaluationNode, { key: AI_OTHERS_NODE_CREATOR_VIEW, type: 'view', @@ -424,6 +437,18 @@ export function TriggerView() { icon: 'fa:comments', }, }, + { + key: EVALUATION_TRIGGER_NODE_TYPE, + type: 'node', + category: [CORE_NODES_CATEGORY], + properties: { + group: [], + name: EVALUATION_TRIGGER_NODE_TYPE, + displayName: 'Evaluation Trigger', + description: 'Run a dataset through your workflow to test performance', + icon: 'fa:check-double', + }, + }, { type: 'subcategory', key: OTHER_TRIGGER_NODES_SUBCATEGORY, diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 5a8ae6ff33..fcc7ef2a0b 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -217,6 +217,7 @@ export const SLACK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.slackTrigger'; export const TELEGRAM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.telegramTrigger'; export const FACEBOOK_LEAD_ADS_TRIGGER_NODE_TYPE = 'n8n-nodes-base.facebookLeadAdsTrigger'; export const RESPOND_TO_WEBHOOK_NODE_TYPE = 'n8n-nodes-base.respondToWebhook'; +export const EVALUATION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.evaluationTrigger'; export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base'; export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1; @@ -746,6 +747,12 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [ export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const NODE_RESOURCE_FIELD_NAME = 'resource'; +export const EVALUATION_TRIGGER = { + name: '031-evaluation-trigger', + control: 'control', + variant: 'variant', +}; + export const EASY_AI_WORKFLOW_EXPERIMENT = { name: '026_easy_ai_workflow', control: 'control', diff --git a/packages/nodes-base/nodes/Evaluation/Evaluation/Description.node.ts b/packages/nodes-base/nodes/Evaluation/Evaluation/Description.node.ts new file mode 100644 index 0000000000..d5a9fb07c0 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/Evaluation/Description.node.ts @@ -0,0 +1,119 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { document, sheet } from '../../Google/Sheet/GoogleSheetsTrigger.node'; + +export const setOutputProperties: INodeProperties[] = [ + { + displayName: 'Credentials', + name: 'credentials', + type: 'credentials', + default: '', + }, + { + ...document, + displayOptions: { + show: { + operation: ['setOutputs'], + }, + }, + }, + { + ...sheet, + displayOptions: { + show: { + operation: ['setOutputs'], + }, + }, + }, + { + displayName: 'Outputs', + name: 'outputs', + placeholder: 'Add Output', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Output', + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Filter', + name: 'values', + values: [ + { + displayName: 'Name', + name: 'outputName', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'outputValue', + type: 'string', + default: '', + }, + ], + }, + ], + displayOptions: { + show: { + operation: ['setOutputs'], + }, + }, + }, +]; + +export const setCheckIfEvaluatingProperties: INodeProperties[] = [ + { + displayName: + 'Routes to the ‘evaluation’ branch if the execution started from an evaluation trigger. Otherwise routes to the ‘normal’ branch.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + operation: ['checkIfEvaluating'], + }, + }, + }, +]; + +export const setMetricsProperties: INodeProperties[] = [ + { + displayName: + "Calculate the score(s) for the evaluation, then map them into this node. They will be displayed in the ‘evaluations’ tab, not the Google Sheet. View metric examples", + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + operation: ['setMetrics'], + }, + }, + }, + { + displayName: 'Metrics to Return', + name: 'metrics', + type: 'assignmentCollection', + default: { + assignments: [ + { + name: '', + value: '', + type: 'number', + }, + ], + }, + typeOptions: { + assignment: { + disableType: true, + defaultType: 'number', + }, + }, + displayOptions: { + show: { + operation: ['setMetrics'], + }, + }, + }, +]; diff --git a/packages/nodes-base/nodes/Evaluation/Evaluation/Evaluation.node.ee.json b/packages/nodes-base/nodes/Evaluation/Evaluation/Evaluation.node.ee.json new file mode 100644 index 0000000000..719ed0fee8 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/Evaluation/Evaluation.node.ee.json @@ -0,0 +1,15 @@ +{ + "node": "n8n-nodes-base.evaluation", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Utility"], + "resources": { + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.evaluation/" + } + ], + "generic": [] + }, + "alias": ["Test", "Metrics", "Evals", "Set Output", "Set Metrics"] +} diff --git a/packages/nodes-base/nodes/Evaluation/Evaluation/Evaluation.node.ee.ts b/packages/nodes-base/nodes/Evaluation/Evaluation/Evaluation.node.ee.ts new file mode 100644 index 0000000000..26bd2fb473 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/Evaluation/Evaluation.node.ee.ts @@ -0,0 +1,101 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { + IExecuteFunctions, + INodeType, + INodeTypeDescription, + INodeExecutionData, +} from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { + setCheckIfEvaluatingProperties, + setMetricsProperties, + setOutputProperties, +} from './Description.node'; +import { authentication } from '../../Google/Sheet/v2/actions/versionDescription'; +import { listSearch, loadOptions } from '../methods'; +import { checkIfEvaluating, setMetrics, setOutputs, setOutput } from '../utils/evaluationUtils'; + +export class Evaluation implements INodeType { + description: INodeTypeDescription = { + displayName: 'Evaluation', + icon: 'fa:check-double', + name: 'evaluation', + group: ['transform'], + version: 4.6, + description: 'Runs an evaluation', + eventTriggerDescription: '', + subtitle: '={{$parameter["operation"]}}', + defaults: { + name: 'Evaluation', + color: '#c3c9d5', + }, + inputs: [NodeConnectionTypes.Main], + outputs: `={{(${setOutputs})($parameter)}}`, + credentials: [ + { + name: 'googleApi', + required: true, + displayOptions: { + show: { + authentication: ['serviceAccount'], + operation: ['setOutputs'], + }, + }, + testedBy: 'googleApiCredentialTest', + }, + { + name: 'googleSheetsOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + operation: ['setOutputs'], + }, + }, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Set Outputs', + value: 'setOutputs', + }, + { + name: 'Set Metrics', + value: 'setMetrics', + }, + { + name: 'Check If Evaluating', + value: 'checkIfEvaluating', + }, + ], + default: 'setOutputs', + }, + authentication, + ...setOutputProperties, + ...setMetricsProperties, + ...setCheckIfEvaluatingProperties, + ], + }; + + methods = { loadOptions, listSearch }; + + async execute(this: IExecuteFunctions): Promise { + const operation = this.getNodeParameter('operation', 0); + + if (operation === 'setOutputs') { + return await setOutput.call(this); + } else if (operation === 'setMetrics') { + return await setMetrics.call(this); + } else { + // operation === 'checkIfEvaluating' + return await checkIfEvaluating.call(this); + } + } +} diff --git a/packages/nodes-base/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.json b/packages/nodes-base/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.json new file mode 100644 index 0000000000..abccc912d8 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.json @@ -0,0 +1,15 @@ +{ + "node": "n8n-nodes-base.evaluationTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Utility"], + "resources": { + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.evaluationTrigger/" + } + ], + "generic": [] + }, + "alias": ["Test", "Metrics", "Evals", "Set Output", "Set Metrics"] +} diff --git a/packages/nodes-base/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.ts b/packages/nodes-base/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.ts new file mode 100644 index 0000000000..f8ce47d72f --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.ts @@ -0,0 +1,198 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { + INodeType, + INodeTypeDescription, + IExecuteFunctions, + INodeExecutionData, +} from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; + +import { document, sheet } from '../../Google/Sheet/GoogleSheetsTrigger.node'; +import { readFilter } from '../../Google/Sheet/v2/actions/sheet/read.operation'; +import { authentication } from '../../Google/Sheet/v2/actions/versionDescription'; +import type { ILookupValues } from '../../Google/Sheet/v2/helpers/GoogleSheets.types'; +import { listSearch, loadOptions } from '../methods'; +import { + getGoogleSheet, + getResults, + getRowsLeft, + getNumberOfRowsLeftFiltered, + getSheet, +} from '../utils/evaluationTriggerUtils'; + +export let startingRow = 2; + +export class EvaluationTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Evaluation Trigger', + icon: 'fa:check-double', + name: 'evaluationTrigger', + group: ['trigger'], + version: 4.6, + description: 'Run a test dataset through your workflow to check performance', + eventTriggerDescription: '', + defaults: { + name: 'When fetching a dataset row', + color: '#c3c9d5', + }, + inputs: [], + outputs: [NodeConnectionTypes.Main], + properties: [ + { + displayName: + 'Pulls a test dataset from a Google Sheet. The workflow will run once for each row, in sequence. Tips for wiring this node up here.', + name: 'notice', + type: 'notice', + default: '', + }, + { + displayName: 'Credentials', + name: 'credentials', + type: 'credentials', + default: '', + }, + authentication, + document, + sheet, + { + displayName: 'Limit Rows', + name: 'limitRows', + type: 'boolean', + default: false, + noDataExpression: true, + description: 'Whether to limit number of rows to process', + }, + { + displayName: 'Max Rows to Process', + name: 'maxRows', + type: 'number', + default: 10, + description: 'Maximum number of rows to process', + noDataExpression: false, + displayOptions: { show: { limitRows: [true] } }, + }, + readFilter, + ], + credentials: [ + { + name: 'googleApi', + required: true, + displayOptions: { + show: { + authentication: ['serviceAccount'], + }, + }, + testedBy: 'googleApiCredentialTest', + }, + { + name: 'googleSheetsOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + ], + }; + + methods = { loadOptions, listSearch }; + + async execute(this: IExecuteFunctions, startRow?: number): Promise { + // We need to allow tests to reset the startingRow + if (startRow) { + startingRow = startRow; + } + + const inputData = this.getInputData(); + + const MAX_ROWS = 1000; + + const maxRows = this.getNodeParameter('limitRows', 0) + ? (this.getNodeParameter('maxRows', 0) as number) + 1 + : MAX_ROWS; + + const rangeOptions = { + rangeDefinition: 'specifyRange', + headerRow: 1, + firstDataRow: startingRow, + }; + + const googleSheetInstance = getGoogleSheet.call(this); + + const googleSheet = await getSheet.call(this, googleSheetInstance); + + const allRows = await getResults.call(this, [], googleSheetInstance, googleSheet, rangeOptions); + + // This is for test runner which requires a different return format + if (inputData[0].json.requestDataset) { + const testRunnerResult = await getResults.call( + this, + [], + googleSheetInstance, + googleSheet, + {}, + ); + + const result = testRunnerResult.filter((row) => (row?.json?.row_number as number) <= maxRows); + + return [result]; + } + + const hasFilter = this.getNodeParameter('filtersUI.values', 0, []) as ILookupValues[]; + + if (hasFilter.length > 0) { + const currentRow = allRows[0]; + const currentRowNumber = currentRow.json?.row_number as number; + + if (currentRow === undefined) { + startingRow = 2; + + throw new NodeOperationError(this.getNode(), 'No row found'); + } + + const rowsLeft = await getNumberOfRowsLeftFiltered.call( + this, + googleSheetInstance, + googleSheet.title, + currentRowNumber + 1, + maxRows, + ); + + currentRow.json._rowsLeft = rowsLeft; + + startingRow = currentRowNumber + 1; + + if (rowsLeft === 0) { + startingRow = 2; + } + + return [[currentRow]]; + } else { + const currentRow = allRows.find((row) => (row?.json?.row_number as number) === startingRow); + + const rowsLeft = await getRowsLeft.call( + this, + googleSheetInstance, + googleSheet.title, + `${googleSheet.title}!${startingRow}:${maxRows}`, + ); + + if (currentRow === undefined) { + startingRow = 2; + + throw new NodeOperationError(this.getNode(), 'No row found'); + } + + currentRow.json._rowsLeft = rowsLeft; + + startingRow += 1; + + if (rowsLeft === 0) { + startingRow = 2; + } + + return [[currentRow]]; + } + } +} diff --git a/packages/nodes-base/nodes/Evaluation/methods/index.ts b/packages/nodes-base/nodes/Evaluation/methods/index.ts new file mode 100644 index 0000000000..5e66ef4e08 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/methods/index.ts @@ -0,0 +1,2 @@ +export * as loadOptions from './loadOptions'; +export * as listSearch from './../../Google/Sheet/v2/methods/listSearch'; diff --git a/packages/nodes-base/nodes/Evaluation/methods/loadOptions.ts b/packages/nodes-base/nodes/Evaluation/methods/loadOptions.ts new file mode 100644 index 0000000000..10dac6901d --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/methods/loadOptions.ts @@ -0,0 +1,17 @@ +import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; + +import { getSheetHeaderRow } from '../../Google/Sheet/v2/methods/loadOptions'; + +export async function getSheetHeaderRowWithGeneratedColumnNames( + this: ILoadOptionsFunctions, +): Promise { + const returnData = await getSheetHeaderRow.call(this); + return returnData.map((column, i) => { + if (column.value !== '') return column; + const indexBasedValue = `col_${i + 1}`; + return { + name: indexBasedValue, + value: indexBasedValue, + }; + }); +} diff --git a/packages/nodes-base/nodes/Evaluation/test/Evaluation.node.test.ts b/packages/nodes-base/nodes/Evaluation/test/Evaluation.node.test.ts new file mode 100644 index 0000000000..024afb6257 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/test/Evaluation.node.test.ts @@ -0,0 +1,319 @@ +import { mock } from 'jest-mock-extended'; +import { + NodeOperationError, + type AssignmentCollectionValue, + type IExecuteFunctions, + type INodeTypes, +} from 'n8n-workflow'; + +import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet'; +import { Evaluation } from '../Evaluation/Evaluation.node.ee'; + +describe('Test Evaluation', () => { + const sheetName = 'Sheet5'; + const spreadsheetId = '1oqFpPgEPTGDw7BPkp1SfPXq3Cb3Hyr1SROtf-Ec4zvA'; + + const mockExecuteFunctions = mock({}); + + beforeEach(() => { + (mockExecuteFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]); + (mockExecuteFunctions.getNode as jest.Mock).mockReturnValue({ typeVersion: 4.6 }); + (mockExecuteFunctions.getParentNodes as jest.Mock).mockReturnValue([ + { type: 'n8n-nodes-base.evaluationTrigger', name: 'Evaluation' }, + ]); + (mockExecuteFunctions.evaluateExpression as jest.Mock).mockReturnValue({ + row_number: 23, + foo: 1, + bar: 2, + _rowsLeft: 2, + }); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('Test Evaluation Node for Set Output', () => { + jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => { + return { sheetId: 1, title: sheetName }; + }); + + jest.spyOn(GoogleSheet.prototype, 'updateRows').mockImplementation(async () => { + return { sheetId: 1, title: sheetName }; + }); + + jest.spyOn(GoogleSheet.prototype, 'batchUpdate').mockImplementation(async () => { + return { sheetId: 1, title: sheetName }; + }); + + test('should throw error if output values is empty', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + 'outputs.values': [], + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + operation: 'setOutputs', + }; + return mockParams[key] ?? fallbackValue; + }, + ); + + await expect(new Evaluation().execute.call(mockExecuteFunctions)).rejects.toThrow( + 'No outputs to set', + ); + + expect(GoogleSheet.prototype.updateRows).not.toBeCalled(); + + expect(GoogleSheet.prototype.batchUpdate).not.toBeCalled(); + }); + + test('should update rows and return input data for existing headers', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + 'outputs.values': [{ outputName: 'foo', outputValue: 'clam' }], + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + operation: 'setOutputs', + }; + return mockParams[key] ?? fallbackValue; + }, + ); + + await new Evaluation().execute.call(mockExecuteFunctions); + + expect(GoogleSheet.prototype.updateRows).toHaveBeenCalledWith( + sheetName, + [['foo', 'bar']], + 'RAW', + 1, + ); + + expect(GoogleSheet.prototype.batchUpdate).toHaveBeenCalledWith( + [ + { + range: 'Sheet5!A23', + values: [['clam']], + }, + ], + 'RAW', + ); + }); + + test('should return empty when there is no parent evaluation trigger', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + 'outputs.values': [{ outputName: 'bob', outputValue: 'clam' }], + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + operation: 'setOutputs', + }; + return mockParams[key] ?? fallbackValue; + }, + ); + mockExecuteFunctions.getParentNodes.mockReturnValue([]); + + const result = await new Evaluation().execute.call(mockExecuteFunctions); + + expect(result).toEqual([]); + + expect(GoogleSheet.prototype.updateRows).not.toBeCalled(); + + expect(GoogleSheet.prototype.batchUpdate).not.toBeCalled(); + }); + + test('should update rows and return input data for new headers', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + 'outputs.values': [{ outputName: 'bob', outputValue: 'clam' }], + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + operation: 'setOutputs', + }; + return mockParams[key] ?? fallbackValue; + }, + ); + + await new Evaluation().execute.call(mockExecuteFunctions); + + expect(GoogleSheet.prototype.updateRows).toHaveBeenCalledWith( + sheetName, + [['foo', 'bar', 'bob']], + 'RAW', + 1, + ); + + expect(GoogleSheet.prototype.batchUpdate).toHaveBeenCalledWith( + [ + { + range: 'Sheet5!C23', + values: [['clam']], + }, + ], + 'RAW', + ); + }); + }); + + describe('Test Evaluation Node for Set Metrics', () => { + const nodeTypes = mock(); + const evaluationMetricsNode = new Evaluation(); + + let mockExecuteFunction: IExecuteFunctions; + + function getMockExecuteFunction(metrics: AssignmentCollectionValue['assignments']) { + return { + getInputData: jest.fn().mockReturnValue([{}]), + + getNodeParameter: jest.fn((param: string, _: number) => { + if (param === 'metrics') { + return { assignments: metrics }; + } + if (param === 'operation') { + return 'setMetrics'; + } + return param; + }), + + getNode: jest.fn().mockReturnValue({ + typeVersion: 1, + }), + } as unknown as IExecuteFunctions; + } + + beforeAll(() => { + mockExecuteFunction = getMockExecuteFunction([ + { + id: '1', + name: 'Accuracy', + value: 0.95, + type: 'number', + }, + { + id: '2', + name: 'Latency', + value: 100, + type: 'number', + }, + ]); + nodeTypes.getByName.mockReturnValue(evaluationMetricsNode); + jest.clearAllMocks(); + }); + + describe('execute', () => { + it('should output the defined metrics', async () => { + const result = await evaluationMetricsNode.execute.call(mockExecuteFunction); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(1); + + const outputItem = result[0][0].json; + expect(outputItem).toEqual({ + Accuracy: 0.95, + Latency: 100, + }); + }); + + it('should handle no metrics defined', async () => { + mockExecuteFunction = getMockExecuteFunction([]); + const result = await evaluationMetricsNode.execute.call(mockExecuteFunction); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(1); + expect(result[0][0].json).toEqual({}); + }); + + it('should convert string values to numbers', async () => { + const mockExecuteWithStringValues = getMockExecuteFunction([ + { + id: '1', + name: 'Accuracy', + value: '0.95', + type: 'number', + }, + { + id: '2', + name: 'Latency', + value: '100', + type: 'number', + }, + ]); + + const result = await evaluationMetricsNode.execute.call(mockExecuteWithStringValues); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(1); + + const outputItem = result[0][0].json; + expect(outputItem).toEqual({ + Accuracy: 0.95, + Latency: 100, + }); + }); + + it('should throw error for non-numeric string values', async () => { + const mockExecuteWithInvalidValue = getMockExecuteFunction([ + { + id: '1', + name: 'Accuracy', + value: 'not-a-number', + type: 'number', + }, + ]); + + await expect( + evaluationMetricsNode.execute.call(mockExecuteWithInvalidValue), + ).rejects.toThrow(NodeOperationError); + }); + }); + }); + + describe('Test Evaluation Node for Check If Evaluating', () => { + beforeEach(() => { + (mockExecuteFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]); + (mockExecuteFunctions.getNode as jest.Mock).mockReturnValue({ typeVersion: 4.6 }); + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + operation: 'checkIfEvaluating', + }; + return mockParams[key] ?? fallbackValue; + }, + ); + }); + + afterEach(() => jest.clearAllMocks()); + + test('should return output in normal branch if normal execution', async () => { + (mockExecuteFunctions.getParentNodes as jest.Mock).mockReturnValue([]); + const result = await new Evaluation().execute.call(mockExecuteFunctions); + expect(result).toEqual([[], [{ json: {} }]]); + }); + + test('should return output in evaluation branch if evaluation execution', async () => { + (mockExecuteFunctions.getParentNodes as jest.Mock).mockReturnValue([ + { type: 'n8n-nodes-base.evaluationTrigger', name: 'Evaluation' }, + ]); + + const result = await new Evaluation().execute.call(mockExecuteFunctions); + expect(result).toEqual([[{ json: {} }], []]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Evaluation/test/EvaluationTrigger.node.test.ts b/packages/nodes-base/nodes/Evaluation/test/EvaluationTrigger.node.test.ts new file mode 100644 index 0000000000..cefb9432d0 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/test/EvaluationTrigger.node.test.ts @@ -0,0 +1,321 @@ +import { mock } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; + +import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet'; +import { EvaluationTrigger, startingRow } from '../EvaluationTrigger/EvaluationTrigger.node.ee'; +import * as utils from '../utils/evaluationTriggerUtils'; + +describe('Evaluation Trigger Node', () => { + const sheetName = 'Sheet5'; + const spreadsheetId = '1oqFpPgEPTGDw7BPkp1SfPXq3Cb3Hyr1SROtf-Ec4zvA'; + + let mockExecuteFunctions = mock({ + getInputData: jest.fn().mockReturnValue([{ json: {} }]), + getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }), + }); + + describe('Without filters', () => { + beforeEach(() => { + jest.resetAllMocks(); + + mockExecuteFunctions = mock({ + getInputData: jest.fn().mockReturnValue([{ json: {} }]), + getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }), + }); + + jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => { + return { sheetId: 1, title: sheetName }; + }); + + // Mocks getResults() and getRowsLeft() + jest.spyOn(GoogleSheet.prototype, 'getData').mockImplementation(async (range: string) => { + if (range === `${sheetName}!1:1`) { + return [['Header1', 'Header2']]; + } else if (range === `${sheetName}!2:1000`) { + return [ + ['Header1', 'Header2'], + ['Value1', 'Value2'], + ['Value3', 'Value4'], + ]; + } else if (range === `${sheetName}!2:2`) { + // getRowsLeft with limit + return []; + } else if (range === sheetName) { + return [ + ['Header1', 'Header2'], + ['Value1', 'Value2'], + ['Value3', 'Value4'], + ]; + } else { + return []; + } + }); + }); + + test('should return a single row from google sheet', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + options: {}, + 'filtersUI.values': [], + combineFilters: 'AND', + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + }; + return mockParams[key] ?? fallbackValue; + }, + ); + + const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions); + + expect(result).toEqual([ + [ + { + json: { + row_number: 2, + Header1: 'Value1', + Header2: 'Value2', + _rowsLeft: 2, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + + expect(startingRow).toBe(3); + }); + + test('should return a single row from google sheet with limit', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + options: {}, + 'filtersUI.values': [], + combineFilters: 'AND', + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + limitRows: true, + maxRows: 1, + }; + return mockParams[key] ?? fallbackValue; + }, + ); + + const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions, 2); + + expect(result).toEqual([ + [ + { + json: { + row_number: 2, + Header1: 'Value1', + Header2: 'Value2', + _rowsLeft: 0, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + + expect(startingRow).toBe(2); + }); + + test('should return the sheet with limits applied when test runner is enabled', async () => { + mockExecuteFunctions.getInputData.mockReturnValue([{ json: { requestDataset: true } }]); + + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + options: {}, + 'filtersUI.values': [], + combineFilters: 'AND', + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + limitRows: true, + maxRows: 2, + }; + return mockParams[key] ?? fallbackValue; + }, + ); + + const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions, 2); + + expect(result).toEqual([ + [ + { + json: { + row_number: 2, + Header1: 'Value1', + Header2: 'Value2', + }, + pairedItem: { + item: 0, + }, + }, + { + json: { + row_number: 3, + Header1: 'Value3', + Header2: 'Value4', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + + expect(startingRow).toBe(2); + }); + }); + + describe('With filters', () => { + beforeEach(() => { + jest.resetAllMocks(); + + mockExecuteFunctions = mock({ + getInputData: jest.fn().mockReturnValue([{ json: {} }]), + getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }), + }); + + jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => { + return { sheetId: 1, title: sheetName }; + }); + }); + + test('should return all relevant rows from google sheet using filter and test runner enabled', async () => { + mockExecuteFunctions.getInputData.mockReturnValue([{ json: { requestDataset: true } }]); + + jest + .spyOn(GoogleSheet.prototype, 'getData') + .mockResolvedValueOnce([ + // operationResult + ['Header1', 'Header2'], + ['Value1', 'Value2'], + ['Value3', 'Value4'], + ['Value1', 'Value4'], + ]) + .mockResolvedValueOnce([ + // rowsLeft + ['Header1', 'Header2'], + ['Value1', 'Value2'], + ['Value3', 'Value4'], + ['Value1', 'Value4'], + ]); + + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + 'filtersUI.values': [{ lookupColumn: 'Header1', lookupValue: 'Value1' }], + options: {}, + combineFilters: 'AND', + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + }; + return mockParams[key] ?? fallbackValue; + }, + ); + + jest.spyOn(utils, 'getRowsLeft').mockResolvedValue(0); + + const evaluationTrigger = new EvaluationTrigger(); + + const result = await evaluationTrigger.execute.call(mockExecuteFunctions, 1); + + expect(result).toEqual([ + [ + { + json: { row_number: 2, Header1: 'Value1', Header2: 'Value2' }, + pairedItem: { + item: 0, + }, + }, + { + json: { row_number: 4, Header1: 'Value1', Header2: 'Value4' }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + + test('should return a single row from google sheet using filter', async () => { + jest + .spyOn(GoogleSheet.prototype, 'getData') + .mockResolvedValueOnce([ + // operationResult + ['Header1', 'Header2'], + ['Value1', 'Value2'], + ['Value3', 'Value4'], + ]) + .mockResolvedValueOnce([ + // rowsLeft + ['Header1', 'Header2'], + ['Value1', 'Value2'], + ['Value3', 'Value4'], + ]); + + mockExecuteFunctions.getNodeParameter.mockImplementation( + (key: string, _: number, fallbackValue?: string | number | boolean | object) => { + const mockParams: { [key: string]: unknown } = { + limitRows: true, + maxRows: 2, + 'filtersUI.values': [{ lookupColumn: 'Header1', lookupValue: 'Value1' }], + options: {}, + combineFilters: 'AND', + documentId: { + mode: 'id', + value: spreadsheetId, + }, + sheetName, + sheetMode: 'id', + }; + return mockParams[key] ?? fallbackValue; + }, + ); + + jest.spyOn(utils, 'getRowsLeft').mockResolvedValue(0); + + const evaluationTrigger = new EvaluationTrigger(); + + const result = await evaluationTrigger.execute.call(mockExecuteFunctions, 1); + + expect(result).toEqual([ + [ + { + json: { + row_number: 2, + Header1: 'Value1', + Header2: 'Value2', + _rowsLeft: 0, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Evaluation/test/evaluationTriggerUtils.test.ts b/packages/nodes-base/nodes/Evaluation/test/evaluationTriggerUtils.test.ts new file mode 100644 index 0000000000..fd9bba0513 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/test/evaluationTriggerUtils.test.ts @@ -0,0 +1,101 @@ +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; + +import { readSheet } from '../../Google/Sheet/v2/actions/utils/readOperation'; +import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet'; +import { getFilteredResults } from '../utils/evaluationTriggerUtils'; + +jest.mock('../../Google/Sheet/v2/actions/utils/readOperation', () => ({ + readSheet: jest.fn(), +})); + +describe('getFilteredResults', () => { + let mockThis: IExecuteFunctions; + let mockGoogleSheet: GoogleSheet; + + beforeEach(() => { + // Mock the `this` context + mockThis = { + getNode: jest.fn().mockReturnValue({ typeVersion: 1 }), + } as unknown as IExecuteFunctions; + + // Mock the GoogleSheet instance + mockGoogleSheet = new GoogleSheet('mockSpreadsheetId', mockThis); + + // Reset mocks before each test + jest.clearAllMocks(); + }); + + it('should return filtered results based on endingRow', async () => { + // Arrange + const mockOperationResult: INodeExecutionData[] = []; + const mockResult = { title: 'Sheet1', sheetId: 1 }; + const startingRow = 1; + const endingRow = 3; + + (readSheet as jest.Mock).mockResolvedValue([ + { json: { row_number: 1, data: 'Row 1' } }, + { json: { row_number: 2, data: 'Row 2' } }, + { json: { row_number: 3, data: 'Row 3' } }, + { json: { row_number: 4, data: 'Row 4' } }, + ]); + + // Act + const result = await getFilteredResults.call( + mockThis, + mockOperationResult, + mockGoogleSheet, + mockResult, + startingRow, + endingRow, + ); + + // Assert + expect(readSheet).toHaveBeenCalledWith( + mockGoogleSheet, + 'Sheet1', + 0, + mockOperationResult, + 1, + [], + undefined, + { + rangeDefinition: 'specifyRange', + headerRow: 1, + firstDataRow: startingRow, + }, + ); + + expect(result).toEqual([ + { json: { row_number: 1, data: 'Row 1' } }, + { json: { row_number: 2, data: 'Row 2' } }, + { json: { row_number: 3, data: 'Row 3' } }, + ]); + }); + + it('should return an empty array if no rows match the filter', async () => { + // Arrange + const mockOperationResult: INodeExecutionData[] = []; + const mockResult = { title: 'Sheet1', sheetId: 1 }; + const startingRow = 1; + const endingRow = 0; + + (readSheet as jest.Mock).mockResolvedValue([ + { json: { row_number: 1, data: 'Row 1' } }, + { json: { row_number: 2, data: 'Row 2' } }, + ]); + + // Act + const result = await getFilteredResults.call( + mockThis, + mockOperationResult, + mockGoogleSheet, + mockResult, + startingRow, + endingRow, + ); + + // Assert + expect(readSheet).toHaveBeenCalled(); + expect(result).toEqual([]); + }); +}); diff --git a/packages/nodes-base/nodes/Evaluation/test/loadOptions.test.ts b/packages/nodes-base/nodes/Evaluation/test/loadOptions.test.ts new file mode 100644 index 0000000000..8cdee31895 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/test/loadOptions.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */ +import { type ILoadOptionsFunctions } from 'n8n-workflow'; + +import { getSheetHeaderRow } from '../../Google/Sheet/v2/methods/loadOptions'; +import { getSheetHeaderRowWithGeneratedColumnNames } from '../methods/loadOptions'; + +jest.mock('../../Google/Sheet/v2/methods/loadOptions', () => ({ + getSheetHeaderRow: jest.fn(), +})); + +describe('getSheetHeaderRowWithGeneratedColumnNames', () => { + let mockThis: ILoadOptionsFunctions; + + beforeEach(() => { + mockThis = { + getNodeParameter: jest.fn(), + getCredentials: jest.fn(), + } as unknown as ILoadOptionsFunctions; + + jest.clearAllMocks(); + }); + + it('should return column names as-is if they are not empty', async () => { + (getSheetHeaderRow as jest.Mock).mockResolvedValue([ + { name: 'Column1', value: 'Column1' }, + { name: 'Column2', value: 'Column2' }, + ]); + + const result = await getSheetHeaderRowWithGeneratedColumnNames.call(mockThis); + + expect(getSheetHeaderRow).toHaveBeenCalled(); + expect(result).toEqual([ + { name: 'Column1', value: 'Column1' }, + { name: 'Column2', value: 'Column2' }, + ]); + }); + + it('should generate column names for empty values', async () => { + (getSheetHeaderRow as jest.Mock).mockResolvedValue([ + { name: '', value: '' }, + { name: 'Column2', value: 'Column2' }, + { name: '', value: '' }, + ]); + + const result = await getSheetHeaderRowWithGeneratedColumnNames.call(mockThis); + + expect(getSheetHeaderRow).toHaveBeenCalled(); + expect(result).toEqual([ + { name: 'col_1', value: 'col_1' }, + { name: 'Column2', value: 'Column2' }, + { name: 'col_3', value: 'col_3' }, + ]); + }); + + it('should handle an empty header row gracefully', async () => { + (getSheetHeaderRow as jest.Mock).mockResolvedValue([]); + + const result = await getSheetHeaderRowWithGeneratedColumnNames.call(mockThis); + + expect(getSheetHeaderRow).toHaveBeenCalled(); + expect(result).toEqual([]); + }); +}); diff --git a/packages/nodes-base/nodes/Evaluation/utils/evaluationTriggerUtils.ts b/packages/nodes-base/nodes/Evaluation/utils/evaluationTriggerUtils.ts new file mode 100644 index 0000000000..97ea09384c --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/utils/evaluationTriggerUtils.ts @@ -0,0 +1,131 @@ +import type { IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; + +import { readSheet } from '../../Google/Sheet/v2/actions/utils/readOperation'; +import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet'; +import type { ResourceLocator } from '../../Google/Sheet/v2/helpers/GoogleSheets.types'; +import { getSpreadsheetId } from '../../Google/Sheet/v2/helpers/GoogleSheets.utils'; + +export async function getSheet( + this: IExecuteFunctions, + googleSheet: GoogleSheet, +): Promise<{ + title: string; + sheetId: number; +}> { + const sheetWithinDocument = this.getNodeParameter('sheetName', 0, undefined, { + extractValue: true, + }) as string; + const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as { + mode: ResourceLocator; + }; + + return await googleSheet.spreadsheetGetSheet(this.getNode(), sheetMode, sheetWithinDocument); +} + +export function getGoogleSheet(this: IExecuteFunctions) { + const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject; + const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string); + + const googleSheet = new GoogleSheet(spreadsheetId, this); + + return googleSheet; +} + +export async function getFilteredResults( + this: IExecuteFunctions, + operationResult: INodeExecutionData[], + googleSheet: GoogleSheet, + result: { title: string; sheetId: number }, + startingRow: number, + endingRow: number, +): Promise { + const sheetName = result.title; + + operationResult = await readSheet.call( + this, + googleSheet, + sheetName, + 0, + operationResult, + this.getNode().typeVersion, + [], + undefined, + { + rangeDefinition: 'specifyRange', + headerRow: 1, + firstDataRow: startingRow, + }, + ); + + return operationResult.filter((row) => (row?.json?.row_number as number) <= endingRow); +} + +export async function getNumberOfRowsLeftFiltered( + this: IExecuteFunctions, + googleSheet: GoogleSheet, + sheetName: string, + startingRow: number, + endingRow: number, +) { + const remainderSheet: INodeExecutionData[] = await readSheet.call( + this, + googleSheet, + sheetName, + 0, + [], + this.getNode().typeVersion, + [], + undefined, + { + rangeDefinition: 'specifyRange', + headerRow: 1, + firstDataRow: startingRow, + }, + ); + + return remainderSheet.filter((row) => (row?.json?.row_number as number) <= endingRow).length; +} + +export async function getResults( + this: IExecuteFunctions, + operationResult: INodeExecutionData[], + googleSheet: GoogleSheet, + result: { title: string; sheetId: number }, + rangeOptions: IDataObject, +): Promise { + const sheetName = result.title; + + operationResult = await readSheet.call( + this, + googleSheet, + sheetName, + 0, + operationResult, + this.getNode().typeVersion, + [], + undefined, + rangeOptions, + ); + + return operationResult; +} + +export async function getRowsLeft( + this: IExecuteFunctions, + googleSheet: GoogleSheet, + sheetName: string, + rangeString: string, +) { + const remainderSheet: INodeExecutionData[] = await readSheet.call( + this, + googleSheet, + sheetName, + 0, + [], + this.getNode().typeVersion, + [], + rangeString, + ); + + return remainderSheet.length; +} diff --git a/packages/nodes-base/nodes/Evaluation/utils/evaluationUtils.ts b/packages/nodes-base/nodes/Evaluation/utils/evaluationUtils.ts new file mode 100644 index 0000000000..46f094f906 --- /dev/null +++ b/packages/nodes-base/nodes/Evaluation/utils/evaluationUtils.ts @@ -0,0 +1,182 @@ +import { NodeOperationError, UserError } from 'n8n-workflow'; +import type { + FieldType, + INodeParameters, + AssignmentCollectionValue, + IDataObject, + IExecuteFunctions, + INodeExecutionData, +} from 'n8n-workflow'; + +import { getGoogleSheet, getSheet } from './evaluationTriggerUtils'; +import { composeReturnItem, validateEntry } from '../../Set/v2/helpers/utils'; + +export async function setOutput(this: IExecuteFunctions): Promise { + const evaluationNode = this.getNode(); + const parentNodes = this.getParentNodes(evaluationNode.name); + + const evalTrigger = parentNodes.find((node) => node.type === 'n8n-nodes-base.evaluationTrigger'); + const evalTriggerOutput = evalTrigger + ? this.evaluateExpression(`{{ $('${evalTrigger?.name}').isExecuted }}`, 0) + : undefined; + + if (!evalTrigger || !evalTriggerOutput) { + this.addExecutionHints({ + message: "No outputs were set since the execution didn't start from an evaluation trigger", + location: 'outputPane', + }); + return []; + } + + const outputFields = this.getNodeParameter('outputs.values', 0, []) as Array<{ + outputName: string; + outputValue: string; + }>; + + if (outputFields.length === 0) { + throw new UserError('No outputs to set', { + description: 'Add outputs to write back to the Google Sheet using the ‘Add Output’ button', + }); + } + + const googleSheetInstance = getGoogleSheet.call(this); + const googleSheet = await getSheet.call(this, googleSheetInstance); + + const evaluationTrigger = this.evaluateExpression( + `{{ $('${evalTrigger.name}').first().json }}`, + 0, + ) as IDataObject; + + const rowNumber = + evaluationTrigger.row_number === 'row_number' ? 1 : evaluationTrigger.row_number; + + const columnNames = Object.keys(evaluationTrigger).filter( + (key) => key !== 'row_number' && key !== '_rowsLeft', + ); + + outputFields.forEach(({ outputName }) => { + if (!columnNames.includes(outputName)) { + columnNames.push(outputName); + } + }); + + await googleSheetInstance.updateRows( + googleSheet.title, + [columnNames], + 'RAW', // default value for Value Input Mode + 1, // header row + ); + + const outputs = outputFields.reduce((acc, { outputName, outputValue }) => { + acc[outputName] = outputValue; + return acc; + }, {} as IDataObject); + + const preparedData = googleSheetInstance.prepareDataForUpdatingByRowNumber( + [ + { + row_number: rowNumber, + ...outputs, + }, + ], + `${googleSheet.title}!A:Z`, + [columnNames], + ); + + await googleSheetInstance.batchUpdate( + preparedData.updateData, + 'RAW', // default value for Value Input Mode + ); + + return [this.getInputData()]; +} + +export async function setMetrics(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const metrics: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + const dataToSave = this.getNodeParameter('metrics', i, {}) as AssignmentCollectionValue; + + const newItem: INodeExecutionData = { + json: {}, + pairedItem: { item: i }, + }; + const newData = Object.fromEntries( + (dataToSave?.assignments ?? []).map((assignment) => { + const assignmentValue = + typeof assignment.value === 'number' ? assignment.value : Number(assignment.value); + + if (!assignment.name || isNaN(assignmentValue)) { + throw new NodeOperationError(this.getNode(), 'Metric name missing', { + description: 'Make sure each metric you define has a name', + }); + } + + if (isNaN(assignmentValue)) { + throw new NodeOperationError( + this.getNode(), + `Value for '${assignment.name}' isn't a number`, + { + description: `It’s currently '${assignment.value}'. Metrics must be numeric.`, + }, + ); + } + + const { name, value } = validateEntry( + assignment.name, + assignment.type as FieldType, + assignmentValue, + this.getNode(), + i, + false, + 1, + ); + + return [name, value]; + }), + ); + + const returnItem = composeReturnItem.call( + this, + i, + newItem, + newData, + { dotNotation: false, include: 'none' }, + 1, + ); + metrics.push(returnItem); + } + + return [metrics]; +} + +export async function checkIfEvaluating(this: IExecuteFunctions): Promise { + const evaluationExecutionResult: INodeExecutionData[] = []; + const normalExecutionResult: INodeExecutionData[] = []; + + const evaluationNode = this.getNode(); + const parentNodes = this.getParentNodes(evaluationNode.name); + + const evalTrigger = parentNodes.find((node) => node.type === 'n8n-nodes-base.evaluationTrigger'); + const evalTriggerOutput = evalTrigger + ? this.evaluateExpression(`{{ $('${evalTrigger?.name}').isExecuted }}`, 0) + : undefined; + + if (evalTriggerOutput) { + return [this.getInputData(), normalExecutionResult]; + } else { + return [evaluationExecutionResult, this.getInputData()]; + } +} + +export function setOutputs(parameters: INodeParameters) { + if (parameters.operation === 'checkIfEvaluating') { + return [ + { type: 'main', displayName: 'Evaluation' }, + { type: 'main', displayName: 'Normal' }, + ]; + } + + return [{ type: 'main' }]; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 65b8c4cb94..3e61e467c5 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -507,6 +507,8 @@ "dist/nodes/Emelia/EmeliaTrigger.node.js", "dist/nodes/ERPNext/ERPNext.node.js", "dist/nodes/ErrorTrigger/ErrorTrigger.node.js", + "dist/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.js", + "dist/nodes/Evaluation/Evaluation/Evaluation.node.ee.js", "dist/nodes/Eventbrite/EventbriteTrigger.node.js", "dist/nodes/ExecuteCommand/ExecuteCommand.node.js", "dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js", diff --git a/packages/workflow/src/Constants.ts b/packages/workflow/src/Constants.ts index db399abf1e..8c9e5f5f90 100644 --- a/packages/workflow/src/Constants.ts +++ b/packages/workflow/src/Constants.ts @@ -27,6 +27,7 @@ export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp'; export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest'; export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook'; export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger'; +export const EVALUATION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.evaluationTrigger'; export const ERROR_TRIGGER_NODE_TYPE = 'n8n-nodes-base.errorTrigger'; export const START_NODE_TYPE = 'n8n-nodes-base.start'; export const EXECUTE_WORKFLOW_NODE_TYPE = 'n8n-nodes-base.executeWorkflow'; @@ -46,6 +47,7 @@ export const STARTING_NODE_TYPES = [ EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE, + EVALUATION_TRIGGER_NODE_TYPE, ]; export const SCRIPTING_NODE_TYPES = [ diff --git a/packages/workflow/src/errors/base/base.error.ts b/packages/workflow/src/errors/base/base.error.ts index 50f61ba1e6..97b25a5c25 100644 --- a/packages/workflow/src/errors/base/base.error.ts +++ b/packages/workflow/src/errors/base/base.error.ts @@ -3,8 +3,8 @@ import callsites from 'callsites'; import type { ErrorTags, ErrorLevel, ReportingOptions } from '../error.types'; -export type BaseErrorOptions = { description?: undefined | null } & ErrorOptions & ReportingOptions; - +export type BaseErrorOptions = { description?: string | undefined | null } & ErrorOptions & + ReportingOptions; /** * Base class for all errors */