mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
refactor(core): Reorganize n8n-core and enforce file-name casing (no-changelog) (#12667)
This commit is contained in:
committed by
GitHub
parent
e7f00bcb7f
commit
05858c2153
@@ -0,0 +1,218 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ICredentialsHelper,
|
||||
Expression,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, ExpressionError, NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { describeCommonTests } from './shared-tests';
|
||||
import { ExecuteContext } from '../execute-context';
|
||||
|
||||
describe('ExecuteContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
name: 'Test Node',
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
nullParameter: null,
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const runExecutionData = mock<IRunExecutionData>();
|
||||
const connectionInputData: INodeExecutionData[] = [];
|
||||
const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] };
|
||||
const executeData = mock<IExecuteData>();
|
||||
const runIndex = 0;
|
||||
const closeFn = jest.fn();
|
||||
const abortSignal = mock<AbortSignal>();
|
||||
|
||||
const executeContext = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
describeCommonTests(executeContext, {
|
||||
abortSignal,
|
||||
node,
|
||||
workflow,
|
||||
executeData,
|
||||
runExecutionData,
|
||||
});
|
||||
|
||||
describe('getInputData', () => {
|
||||
const inputIndex = 0;
|
||||
const connectionType = NodeConnectionType.Main;
|
||||
|
||||
afterEach(() => {
|
||||
inputData[connectionType] = [[{ json: { test: 'data' } }]];
|
||||
});
|
||||
|
||||
it('should return the input data correctly', () => {
|
||||
const expectedData = [{ json: { test: 'data' } }];
|
||||
|
||||
expect(executeContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should return an empty array if the input name does not exist', () => {
|
||||
const connectionType = 'nonExistent';
|
||||
expect(executeContext.getInputData(inputIndex, connectionType as NodeConnectionType)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index is out of range', () => {
|
||||
const inputIndex = 2;
|
||||
|
||||
expect(() => executeContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index was not set', () => {
|
||||
inputData.main[inputIndex] = null;
|
||||
|
||||
expect(() => executeContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should throw if parameter is not defined on the node.parameters', () => {
|
||||
expect(() => executeContext.getNodeParameter('invalidParameter', 0)).toThrow(
|
||||
'Could not get parameter',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if the parameter exists but has a null value', () => {
|
||||
const parameter = executeContext.getNodeParameter('nullParameter', 0);
|
||||
|
||||
expect(parameter).toBeNull();
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = executeContext.getNodeParameter('testParameter', 0);
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = executeContext.getNodeParameter('otherParameter', 0, 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
|
||||
it('should handle expression evaluation errors', () => {
|
||||
const error = new ExpressionError('Invalid expression');
|
||||
expression.getParameterValue.mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() => executeContext.getNodeParameter('testParameter', 0)).toThrow(error);
|
||||
expect(error.context.parameter).toEqual('testParameter');
|
||||
});
|
||||
|
||||
it('should handle expression errors on Set nodes (Ticket #PAY-684)', () => {
|
||||
node.type = 'n8n-nodes-base.set';
|
||||
node.continueOnFail = true;
|
||||
|
||||
expression.getParameterValue.mockImplementationOnce(() => {
|
||||
throw new ExpressionError('Invalid expression');
|
||||
});
|
||||
|
||||
const parameter = executeContext.getNodeParameter('testParameter', 0);
|
||||
expect(parameter).toEqual([{ name: undefined, value: undefined }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials = await executeContext.getCredentials<ICredentialDataDecryptedObject>(
|
||||
testCredentialType,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecuteData', () => {
|
||||
it('should return the execute data correctly', () => {
|
||||
expect(executeContext.getExecuteData()).toEqual(executeData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowDataProxy', () => {
|
||||
it('should return the workflow data proxy correctly', () => {
|
||||
const workflowDataProxy = executeContext.getWorkflowDataProxy(0);
|
||||
expect(workflowDataProxy.isProxy).toBe(true);
|
||||
expect(Object.keys(workflowDataProxy.$input)).toEqual([
|
||||
'all',
|
||||
'context',
|
||||
'first',
|
||||
'item',
|
||||
'last',
|
||||
'params',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ICredentialsHelper,
|
||||
Expression,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { describeCommonTests } from './shared-tests';
|
||||
import { ExecuteSingleContext } from '../execute-single-context';
|
||||
|
||||
describe('ExecuteSingleContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
name: 'Test Node',
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const runExecutionData = mock<IRunExecutionData>();
|
||||
const connectionInputData: INodeExecutionData[] = [];
|
||||
const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] };
|
||||
const executeData = mock<IExecuteData>();
|
||||
const runIndex = 0;
|
||||
const itemIndex = 0;
|
||||
const abortSignal = mock<AbortSignal>();
|
||||
|
||||
const executeSingleContext = new ExecuteSingleContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
itemIndex,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
describeCommonTests(executeSingleContext, {
|
||||
abortSignal,
|
||||
node,
|
||||
workflow,
|
||||
executeData,
|
||||
runExecutionData,
|
||||
});
|
||||
|
||||
describe('getInputData', () => {
|
||||
const inputIndex = 0;
|
||||
const connectionType = NodeConnectionType.Main;
|
||||
|
||||
afterEach(() => {
|
||||
inputData[connectionType] = [[{ json: { test: 'data' } }]];
|
||||
});
|
||||
|
||||
it('should return the input data correctly', () => {
|
||||
const expectedData = { json: { test: 'data' } };
|
||||
|
||||
expect(executeSingleContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should return an empty object if the input name does not exist', () => {
|
||||
const connectionType = 'nonExistent';
|
||||
const expectedData = { json: {} };
|
||||
|
||||
expect(
|
||||
executeSingleContext.getInputData(inputIndex, connectionType as NodeConnectionType),
|
||||
).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index is out of range', () => {
|
||||
const inputIndex = 1;
|
||||
|
||||
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index was not set', () => {
|
||||
inputData.main[inputIndex] = null;
|
||||
|
||||
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the value of input with given index was not set', () => {
|
||||
delete inputData.main[inputIndex]![itemIndex];
|
||||
|
||||
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItemIndex', () => {
|
||||
it('should return the item index correctly', () => {
|
||||
expect(executeSingleContext.getItemIndex()).toEqual(itemIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = executeSingleContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = executeSingleContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await executeSingleContext.getCredentials<ICredentialDataDecryptedObject>(
|
||||
testCredentialType,
|
||||
);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecuteData', () => {
|
||||
it('should return the execute data correctly', () => {
|
||||
expect(executeSingleContext.getExecuteData()).toEqual(executeData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowDataProxy', () => {
|
||||
it('should return the workflow data proxy correctly', () => {
|
||||
const workflowDataProxy = executeSingleContext.getWorkflowDataProxy();
|
||||
expect(workflowDataProxy.isProxy).toBe(true);
|
||||
expect(Object.keys(workflowDataProxy.$input)).toEqual([
|
||||
'all',
|
||||
'context',
|
||||
'first',
|
||||
'item',
|
||||
'last',
|
||||
'params',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
Expression,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWebhookDescription,
|
||||
IWebhookData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
import { HookContext } from '../hook-context';
|
||||
|
||||
describe('HookContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const webhookDescription: IWebhookDescription = {
|
||||
name: 'default',
|
||||
httpMethod: 'GET',
|
||||
responseMode: 'onReceived',
|
||||
path: 'testPath',
|
||||
};
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
nodeType.description.webhooks = [webhookDescription];
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const activation: WorkflowActivateMode = 'init';
|
||||
const webhookData = mock<IWebhookData>({
|
||||
webhookDescription: {
|
||||
name: 'default',
|
||||
isFullPath: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hookContext = new HookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
webhookData,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
expression.getSimpleParameterValue.mockImplementation((_, value) => value);
|
||||
});
|
||||
|
||||
describe('getActivationMode', () => {
|
||||
it('should return the activation property', () => {
|
||||
const result = hookContext.getActivationMode();
|
||||
expect(result).toBe(activation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await hookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = hookContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeWebhookUrl', () => {
|
||||
it('should return node webhook url', () => {
|
||||
const url = hookContext.getNodeWebhookUrl('default');
|
||||
|
||||
expect(url).toContain('testPath');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebhookName', () => {
|
||||
it('should return webhook name', () => {
|
||||
const name = hookContext.getWebhookName();
|
||||
|
||||
expect(name).toBe('default');
|
||||
});
|
||||
|
||||
it('should throw an error if webhookData is undefined', () => {
|
||||
const hookContextWithoutWebhookData = new HookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
|
||||
expect(() => hookContextWithoutWebhookData.getWebhookName()).toThrow(ApplicationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebhookDescription', () => {
|
||||
it('should return webhook description', () => {
|
||||
const description = hookContext.getWebhookDescription('default');
|
||||
|
||||
expect(description).toEqual<IWebhookDescription>(webhookDescription);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
Expression,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { LoadOptionsContext } from '../load-options-context';
|
||||
|
||||
describe('LoadOptionsContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const path = 'testPath';
|
||||
|
||||
const loadOptionsContext = new LoadOptionsContext(workflow, node, additionalData, path);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await loadOptionsContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
additionalData.currentNodeParameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
|
||||
const parameter = loadOptionsContext.getCurrentNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = loadOptionsContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = loadOptionsContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
Expression,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
INodeExecutionData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import { NodeExecutionContext } from '../node-execution-context';
|
||||
|
||||
class TestContext extends NodeExecutionContext {}
|
||||
|
||||
describe('NodeExecutionContext', () => {
|
||||
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
|
||||
Container.set(InstanceSettings, instanceSettings);
|
||||
|
||||
const node = mock<INode>();
|
||||
const nodeType = mock<INodeType>({ description: mock() });
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({
|
||||
id: '123',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodeTypes,
|
||||
timezone: 'UTC',
|
||||
expression,
|
||||
});
|
||||
let additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper: mock(),
|
||||
});
|
||||
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
let testContext: TestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
testContext = new TestContext(workflow, node, additionalData, mode);
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
});
|
||||
|
||||
describe('getNode', () => {
|
||||
it('should return a deep copy of the node', () => {
|
||||
const result = testContext.getNode();
|
||||
expect(result).not.toBe(node);
|
||||
expect(JSON.stringify(result)).toEqual(JSON.stringify(node));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflow', () => {
|
||||
it('should return the id, name, and active properties of the workflow', () => {
|
||||
const result = testContext.getWorkflow();
|
||||
|
||||
expect(result).toEqual({ id: '123', name: 'Test Workflow', active: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMode', () => {
|
||||
it('should return the mode property', () => {
|
||||
const result = testContext.getMode();
|
||||
expect(result).toBe(mode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowStaticData', () => {
|
||||
it('should call getStaticData method of workflow', () => {
|
||||
testContext.getWorkflowStaticData('testType');
|
||||
expect(workflow.getStaticData).toHaveBeenCalledWith('testType', node);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChildNodes', () => {
|
||||
it('should return an array of NodeTypeAndVersion objects for the child nodes of the given node', () => {
|
||||
const childNode1 = mock<INode>({ name: 'Child Node 1', type: 'testType1', typeVersion: 1 });
|
||||
const childNode2 = mock<INode>({ name: 'Child Node 2', type: 'testType2', typeVersion: 2 });
|
||||
workflow.getChildNodes.mockReturnValue(['Child Node 1', 'Child Node 2']);
|
||||
workflow.nodes = {
|
||||
'Child Node 1': childNode1,
|
||||
'Child Node 2': childNode2,
|
||||
};
|
||||
|
||||
const result = testContext.getChildNodes('Test Node');
|
||||
|
||||
expect(result).toMatchObject([
|
||||
{ name: 'Child Node 1', type: 'testType1', typeVersion: 1 },
|
||||
{ name: 'Child Node 2', type: 'testType2', typeVersion: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParentNodes', () => {
|
||||
it('should return an array of NodeTypeAndVersion objects for the parent nodes of the given node', () => {
|
||||
const parentNode1 = mock<INode>({ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 });
|
||||
const parentNode2 = mock<INode>({ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 });
|
||||
workflow.getParentNodes.mockReturnValue(['Parent Node 1', 'Parent Node 2']);
|
||||
workflow.nodes = {
|
||||
'Parent Node 1': parentNode1,
|
||||
'Parent Node 2': parentNode2,
|
||||
};
|
||||
|
||||
const result = testContext.getParentNodes('Test Node');
|
||||
|
||||
expect(result).toMatchObject([
|
||||
{ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 },
|
||||
{ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKnownNodeTypes', () => {
|
||||
it('should call getKnownTypes method of nodeTypes', () => {
|
||||
testContext.getKnownNodeTypes();
|
||||
expect(nodeTypes.getKnownTypes).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRestApiUrl', () => {
|
||||
it('should return the restApiUrl property of additionalData', () => {
|
||||
additionalData.restApiUrl = 'https://example.com/api';
|
||||
|
||||
const result = testContext.getRestApiUrl();
|
||||
|
||||
expect(result).toBe('https://example.com/api');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceBaseUrl', () => {
|
||||
it('should return the instanceBaseUrl property of additionalData', () => {
|
||||
additionalData.instanceBaseUrl = 'https://example.com';
|
||||
|
||||
const result = testContext.getInstanceBaseUrl();
|
||||
|
||||
expect(result).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceId', () => {
|
||||
it('should return the instanceId property of instanceSettings', () => {
|
||||
const result = testContext.getInstanceId();
|
||||
|
||||
expect(result).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimezone', () => {
|
||||
it('should return the timezone property of workflow', () => {
|
||||
const result = testContext.getTimezone();
|
||||
expect(result).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentialsProperties', () => {
|
||||
it('should call getCredentialsProperties method of additionalData.credentialsHelper', () => {
|
||||
testContext.getCredentialsProperties('testType');
|
||||
expect(additionalData.credentialsHelper.getCredentialsProperties).toHaveBeenCalledWith(
|
||||
'testType',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareOutputData', () => {
|
||||
it('should return the input array wrapped in another array', async () => {
|
||||
const outputData = [mock<INodeExecutionData>(), mock<INodeExecutionData>()];
|
||||
|
||||
const result = await testContext.prepareOutputData(outputData);
|
||||
|
||||
expect(result).toEqual([outputData]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeInputs', () => {
|
||||
it('should return static inputs array when inputs is an array', () => {
|
||||
nodeType.description.inputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel];
|
||||
|
||||
const result = testContext.getNodeInputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiLanguageModel },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return input objects when inputs contains configurations', () => {
|
||||
nodeType.description.inputs = [
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiLanguageModel, required: true },
|
||||
];
|
||||
|
||||
const result = testContext.getNodeInputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiLanguageModel, required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should evaluate dynamic inputs when inputs is a function', () => {
|
||||
const inputsExpressions = '={{ ["main", "ai_languageModel"] }}';
|
||||
nodeType.description.inputs = inputsExpressions;
|
||||
expression.getSimpleParameterValue.mockReturnValue([
|
||||
NodeConnectionType.Main,
|
||||
NodeConnectionType.AiLanguageModel,
|
||||
]);
|
||||
|
||||
const result = testContext.getNodeInputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiLanguageModel },
|
||||
]);
|
||||
expect(expression.getSimpleParameterValue).toHaveBeenCalledWith(
|
||||
node,
|
||||
inputsExpressions,
|
||||
'internal',
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeOutputs', () => {
|
||||
it('should return static outputs array when outputs is an array', () => {
|
||||
nodeType.description.outputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel];
|
||||
|
||||
const result = testContext.getNodeOutputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiLanguageModel },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return output objects when outputs contains configurations', () => {
|
||||
nodeType.description.outputs = [
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiLanguageModel, required: true },
|
||||
];
|
||||
|
||||
const result = testContext.getNodeOutputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiLanguageModel, required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should evaluate dynamic outputs when outputs is a function', () => {
|
||||
const outputsExpressions = '={{ ["main", "ai_languageModel"] }}';
|
||||
nodeType.description.outputs = outputsExpressions;
|
||||
expression.getSimpleParameterValue.mockReturnValue([
|
||||
NodeConnectionType.Main,
|
||||
NodeConnectionType.AiLanguageModel,
|
||||
]);
|
||||
|
||||
const result = testContext.getNodeOutputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiLanguageModel },
|
||||
]);
|
||||
expect(expression.getSimpleParameterValue).toHaveBeenCalledWith(
|
||||
node,
|
||||
outputsExpressions,
|
||||
'internal',
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add error output when node has continueOnFail error handling', () => {
|
||||
const nodeWithError = mock<INode>({ onError: 'continueErrorOutput' });
|
||||
const contextWithError = new TestContext(workflow, nodeWithError, additionalData, mode);
|
||||
nodeType.description.outputs = [NodeConnectionType.Main];
|
||||
|
||||
const result = contextWithError.getNodeOutputs();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: NodeConnectionType.Main, displayName: 'Success' },
|
||||
{ type: NodeConnectionType.Main, displayName: 'Error', category: 'error' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectedNodes', () => {
|
||||
it('should return connected nodes of given type', () => {
|
||||
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||
const node2 = mock<INode>({ name: 'Node 2', type: 'test', disabled: false });
|
||||
|
||||
workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']);
|
||||
workflow.getNode.mockImplementation((name) => {
|
||||
if (name === 'Node 1') return node1;
|
||||
if (name === 'Node 2') return node2;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
|
||||
|
||||
expect(result).toEqual([node1, node2]);
|
||||
expect(workflow.getParentNodes).toHaveBeenCalledWith(node.name, NodeConnectionType.Main, 1);
|
||||
});
|
||||
|
||||
it('should filter out disabled nodes', () => {
|
||||
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||
const node2 = mock<INode>({ name: 'Node 2', type: 'test', disabled: true });
|
||||
|
||||
workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']);
|
||||
workflow.getNode.mockImplementation((name) => {
|
||||
if (name === 'Node 1') return node1;
|
||||
if (name === 'Node 2') return node2;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
|
||||
|
||||
expect(result).toEqual([node1]);
|
||||
});
|
||||
|
||||
it('should filter out non-existent nodes', () => {
|
||||
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||
|
||||
workflow.getParentNodes.mockReturnValue(['Node 1', 'NonExistent']);
|
||||
workflow.getNode.mockImplementation((name) => {
|
||||
if (name === 'Node 1') return node1;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
|
||||
|
||||
expect(result).toEqual([node1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
Expression,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { PollContext } from '../poll-context';
|
||||
|
||||
describe('PollContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const activation: WorkflowActivateMode = 'init';
|
||||
|
||||
const pollContext = new PollContext(workflow, node, additionalData, mode, activation);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getActivationMode', () => {
|
||||
it('should return the activation property', () => {
|
||||
const result = pollContext.getActivationMode();
|
||||
expect(result).toBe(activation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await pollContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = pollContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = pollContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { captor, mock, type MockProxy } from 'jest-mock-extended';
|
||||
import type {
|
||||
IRunExecutionData,
|
||||
ContextType,
|
||||
IContextObject,
|
||||
INode,
|
||||
OnError,
|
||||
Workflow,
|
||||
ITaskMetadata,
|
||||
ISourceData,
|
||||
IExecuteData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
ExecuteWorkflowData,
|
||||
RelatedExecution,
|
||||
IExecuteWorkflowInfo,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
|
||||
import { BinaryDataService } from '@/binary-data/binary-data.service';
|
||||
|
||||
import type { BaseExecuteContext } from '../base-execute-context';
|
||||
|
||||
const binaryDataService = mock<BinaryDataService>();
|
||||
Container.set(BinaryDataService, binaryDataService);
|
||||
|
||||
export const describeCommonTests = (
|
||||
context: BaseExecuteContext,
|
||||
{
|
||||
abortSignal,
|
||||
node,
|
||||
workflow,
|
||||
runExecutionData,
|
||||
executeData,
|
||||
}: {
|
||||
abortSignal: AbortSignal;
|
||||
node: INode;
|
||||
workflow: Workflow;
|
||||
runExecutionData: IRunExecutionData;
|
||||
executeData: IExecuteData;
|
||||
},
|
||||
) => {
|
||||
// @ts-expect-error `additionalData` is private
|
||||
const additionalData = context.additionalData as MockProxy<IWorkflowExecuteAdditionalData>;
|
||||
|
||||
describe('getExecutionCancelSignal', () => {
|
||||
it('should return the abort signal', () => {
|
||||
expect(context.getExecutionCancelSignal()).toBe(abortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onExecutionCancellation', () => {
|
||||
const handler = jest.fn();
|
||||
context.onExecutionCancellation(handler);
|
||||
|
||||
const fnCaptor = captor<() => void>();
|
||||
expect(abortSignal.addEventListener).toHaveBeenCalledWith('abort', fnCaptor);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
fnCaptor.value();
|
||||
expect(abortSignal.removeEventListener).toHaveBeenCalledWith('abort', fnCaptor);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('continueOnFail', () => {
|
||||
afterEach(() => {
|
||||
node.onError = undefined;
|
||||
node.continueOnFail = false;
|
||||
});
|
||||
|
||||
it('should return false for nodes by default', () => {
|
||||
expect(context.continueOnFail()).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return true if node has continueOnFail set to true', () => {
|
||||
node.continueOnFail = true;
|
||||
expect(context.continueOnFail()).toEqual(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['continueRegularOutput', true],
|
||||
['continueErrorOutput', true],
|
||||
['stopWorkflow', false],
|
||||
])('if node has onError set to %s, it should return %s', (onError, expected) => {
|
||||
node.onError = onError as OnError;
|
||||
expect(context.continueOnFail()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContext', () => {
|
||||
it('should return the context object', () => {
|
||||
const contextType: ContextType = 'node';
|
||||
const expectedContext = mock<IContextObject>();
|
||||
const getContextSpy = jest.spyOn(NodeHelpers, 'getContext');
|
||||
getContextSpy.mockReturnValue(expectedContext);
|
||||
|
||||
expect(context.getContext(contextType)).toEqual(expectedContext);
|
||||
|
||||
expect(getContextSpy).toHaveBeenCalledWith(runExecutionData, contextType, node);
|
||||
|
||||
getContextSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessageToUI', () => {
|
||||
it('should send console messages to the frontend', () => {
|
||||
context.sendMessageToUI('Testing', 1, 2, {});
|
||||
expect(additionalData.sendDataToUI).toHaveBeenCalledWith('sendConsoleMessage', {
|
||||
source: '[Node: "Test Node"]',
|
||||
messages: ['Testing', 1, 2, {}],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('logAiEvent', () => {
|
||||
it('should log the AI event correctly', () => {
|
||||
const eventName = 'ai-tool-called';
|
||||
const msg = 'test message';
|
||||
|
||||
context.logAiEvent(eventName, msg);
|
||||
|
||||
expect(additionalData.logAiEvent).toHaveBeenCalledWith(eventName, {
|
||||
executionId: additionalData.executionId,
|
||||
nodeName: node.name,
|
||||
workflowName: workflow.name,
|
||||
nodeType: node.type,
|
||||
workflowId: workflow.id,
|
||||
msg,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInputSourceData', () => {
|
||||
it('should return the input source data correctly', () => {
|
||||
const inputSourceData = mock<ISourceData>();
|
||||
executeData.source = { main: [inputSourceData] };
|
||||
|
||||
expect(context.getInputSourceData()).toEqual(inputSourceData);
|
||||
});
|
||||
|
||||
it('should throw an error if the source data is missing', () => {
|
||||
executeData.source = null;
|
||||
|
||||
expect(() => context.getInputSourceData()).toThrow(ApplicationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMetadata', () => {
|
||||
it('sets metadata on execution data', () => {
|
||||
const metadata: ITaskMetadata = {
|
||||
subExecution: {
|
||||
workflowId: '123',
|
||||
executionId: 'xyz',
|
||||
},
|
||||
};
|
||||
|
||||
expect(context.getExecuteData().metadata?.subExecution).toEqual(undefined);
|
||||
context.setMetadata(metadata);
|
||||
expect(context.getExecuteData().metadata?.subExecution).toEqual(metadata.subExecution);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateExpression', () => {
|
||||
it('should evaluate the expression correctly', () => {
|
||||
const expression = '$json.test';
|
||||
const expectedResult = 'data';
|
||||
const resolveSimpleParameterValueSpy = jest.spyOn(
|
||||
workflow.expression,
|
||||
'resolveSimpleParameterValue',
|
||||
);
|
||||
resolveSimpleParameterValueSpy.mockReturnValue(expectedResult);
|
||||
|
||||
expect(context.evaluateExpression(expression, 0)).toEqual(expectedResult);
|
||||
|
||||
expect(resolveSimpleParameterValueSpy).toHaveBeenCalledWith(
|
||||
`=${expression}`,
|
||||
{},
|
||||
runExecutionData,
|
||||
0,
|
||||
0,
|
||||
node.name,
|
||||
[],
|
||||
'manual',
|
||||
expect.objectContaining({}),
|
||||
executeData,
|
||||
);
|
||||
|
||||
resolveSimpleParameterValueSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('putExecutionToWait', () => {
|
||||
it('should set waitTill and execution status', async () => {
|
||||
const waitTill = new Date();
|
||||
|
||||
await context.putExecutionToWait(waitTill);
|
||||
|
||||
expect(runExecutionData.waitTill).toEqual(waitTill);
|
||||
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeWorkflow', () => {
|
||||
const data = [[{ json: { test: true } }]];
|
||||
const executeWorkflowData = mock<ExecuteWorkflowData>();
|
||||
const workflowInfo = mock<IExecuteWorkflowInfo>();
|
||||
const parentExecution: RelatedExecution = {
|
||||
executionId: 'parent_execution_id',
|
||||
workflowId: 'parent_workflow_id',
|
||||
};
|
||||
|
||||
it('should execute workflow and return data', async () => {
|
||||
additionalData.executeWorkflow.mockResolvedValue(executeWorkflowData);
|
||||
binaryDataService.duplicateBinaryData.mockResolvedValue(data);
|
||||
|
||||
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
|
||||
parentExecution,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith(
|
||||
workflow.id,
|
||||
additionalData.executionId,
|
||||
executeWorkflowData.data,
|
||||
);
|
||||
});
|
||||
|
||||
it('should put execution to wait if waitTill is returned', async () => {
|
||||
const waitTill = new Date();
|
||||
additionalData.executeWorkflow.mockResolvedValue({ ...executeWorkflowData, waitTill });
|
||||
binaryDataService.duplicateBinaryData.mockResolvedValue(data);
|
||||
|
||||
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
|
||||
parentExecution,
|
||||
});
|
||||
|
||||
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
|
||||
expect(runExecutionData.waitTill).toEqual(WAIT_INDEFINITELY);
|
||||
expect(result.waitTill).toBe(waitTill);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ICredentialsHelper,
|
||||
Expression,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { describeCommonTests } from './shared-tests';
|
||||
import { SupplyDataContext } from '../supply-data-context';
|
||||
|
||||
describe('SupplyDataContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
name: 'Test Node',
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const runExecutionData = mock<IRunExecutionData>();
|
||||
const connectionInputData: INodeExecutionData[] = [];
|
||||
const connectionType = NodeConnectionType.Main;
|
||||
const inputData: ITaskDataConnections = { [connectionType]: [[{ json: { test: 'data' } }]] };
|
||||
const executeData = mock<IExecuteData>();
|
||||
const runIndex = 0;
|
||||
const closeFn = jest.fn();
|
||||
const abortSignal = mock<AbortSignal>();
|
||||
|
||||
const supplyDataContext = new SupplyDataContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
connectionType,
|
||||
executeData,
|
||||
[closeFn],
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
describeCommonTests(supplyDataContext, {
|
||||
abortSignal,
|
||||
node,
|
||||
workflow,
|
||||
executeData,
|
||||
runExecutionData,
|
||||
});
|
||||
|
||||
describe('getInputData', () => {
|
||||
const inputIndex = 0;
|
||||
|
||||
afterEach(() => {
|
||||
inputData[connectionType] = [[{ json: { test: 'data' } }]];
|
||||
});
|
||||
|
||||
it('should return the input data correctly', () => {
|
||||
const expectedData = [{ json: { test: 'data' } }];
|
||||
|
||||
expect(supplyDataContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should return an empty array if the input name does not exist', () => {
|
||||
const connectionType = 'nonExistent';
|
||||
expect(
|
||||
supplyDataContext.getInputData(inputIndex, connectionType as NodeConnectionType),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index is out of range', () => {
|
||||
const inputIndex = 2;
|
||||
|
||||
expect(() => supplyDataContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the input index was not set', () => {
|
||||
inputData.main[inputIndex] = null;
|
||||
|
||||
expect(() => supplyDataContext.getInputData(inputIndex, connectionType)).toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = supplyDataContext.getNodeParameter('testParameter', 0);
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = supplyDataContext.getNodeParameter('otherParameter', 0, 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials = await supplyDataContext.getCredentials<ICredentialDataDecryptedObject>(
|
||||
testCredentialType,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkflowDataProxy', () => {
|
||||
it('should return the workflow data proxy correctly', () => {
|
||||
const workflowDataProxy = supplyDataContext.getWorkflowDataProxy(0);
|
||||
expect(workflowDataProxy.isProxy).toBe(true);
|
||||
expect(Object.keys(workflowDataProxy.$input)).toEqual([
|
||||
'all',
|
||||
'context',
|
||||
'first',
|
||||
'item',
|
||||
'last',
|
||||
'params',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
Expression,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { TriggerContext } from '../trigger-context';
|
||||
|
||||
describe('TriggerContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const activation: WorkflowActivateMode = 'init';
|
||||
|
||||
const triggerContext = new TriggerContext(workflow, node, additionalData, mode, activation);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getActivationMode', () => {
|
||||
it('should return the activation property', () => {
|
||||
const result = triggerContext.getActivationMode();
|
||||
expect(result).toBe(activation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await triggerContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = triggerContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = triggerContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
Expression,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWebhookData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { WebhookContext } from '../webhook-context';
|
||||
|
||||
describe('WebhookContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
credentials: [
|
||||
{
|
||||
name: testCredentialType,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
name: 'testParameter',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const expression = mock<Expression>();
|
||||
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||
const node = mock<INode>({
|
||||
credentials: {
|
||||
[testCredentialType]: {
|
||||
id: 'testCredentialId',
|
||||
},
|
||||
},
|
||||
});
|
||||
node.parameters = {
|
||||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
credentialsHelper,
|
||||
});
|
||||
additionalData.httpRequest = {
|
||||
body: { test: 'body' },
|
||||
headers: { test: 'header' },
|
||||
params: { test: 'param' },
|
||||
query: { test: 'query' },
|
||||
} as unknown as Request;
|
||||
additionalData.httpResponse = mock<Response>();
|
||||
const mode: WorkflowExecuteMode = 'manual';
|
||||
const webhookData = mock<IWebhookData>({
|
||||
webhookDescription: {
|
||||
name: 'default',
|
||||
},
|
||||
});
|
||||
const runExecutionData = null;
|
||||
|
||||
const webhookContext = new WebhookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
webhookData,
|
||||
[],
|
||||
runExecutionData,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||
|
||||
const credentials =
|
||||
await webhookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentials).toEqual({ secret: 'token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBodyData', () => {
|
||||
it('should return the body data of the request', () => {
|
||||
const bodyData = webhookContext.getBodyData();
|
||||
expect(bodyData).toEqual({ test: 'body' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHeaderData', () => {
|
||||
it('should return the header data of the request', () => {
|
||||
const headerData = webhookContext.getHeaderData();
|
||||
expect(headerData).toEqual({ test: 'header' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParamsData', () => {
|
||||
it('should return the params data of the request', () => {
|
||||
const paramsData = webhookContext.getParamsData();
|
||||
expect(paramsData).toEqual({ test: 'param' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueryData', () => {
|
||||
it('should return the query data of the request', () => {
|
||||
const queryData = webhookContext.getQueryData();
|
||||
expect(queryData).toEqual({ test: 'query' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestObject', () => {
|
||||
it('should return the request object', () => {
|
||||
const request = webhookContext.getRequestObject();
|
||||
expect(request).toBe(additionalData.httpRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseObject', () => {
|
||||
it('should return the response object', () => {
|
||||
const response = webhookContext.getResponseObject();
|
||||
expect(response).toBe(additionalData.httpResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebhookName', () => {
|
||||
it('should return the name of the webhook', () => {
|
||||
const webhookName = webhookContext.getWebhookName();
|
||||
expect(webhookName).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParameter', () => {
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
expression.getParameterValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should return parameter value when it exists', () => {
|
||||
const parameter = webhookContext.getNodeParameter('testParameter');
|
||||
|
||||
expect(parameter).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should return the fallback value when the parameter does not exist', () => {
|
||||
const parameter = webhookContext.getNodeParameter('otherParameter', 'fallback');
|
||||
|
||||
expect(parameter).toBe('fallback');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { get } from 'lodash';
|
||||
import type {
|
||||
Workflow,
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
WorkflowExecuteMode,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
ICredentialDataDecryptedObject,
|
||||
CallbackManager,
|
||||
IExecuteWorkflowInfo,
|
||||
RelatedExecution,
|
||||
ExecuteWorkflowData,
|
||||
ITaskMetadata,
|
||||
ContextType,
|
||||
IContextObject,
|
||||
IWorkflowDataProxyData,
|
||||
ISourceData,
|
||||
AiEvent,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
NodeHelpers,
|
||||
NodeConnectionType,
|
||||
WAIT_INDEFINITELY,
|
||||
WorkflowDataProxy,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { BinaryDataService } from '@/binary-data/binary-data.service';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
|
||||
export class BaseExecuteContext extends NodeExecutionContext {
|
||||
protected readonly binaryDataService = Container.get(BinaryDataService);
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
protected readonly runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
protected readonly connectionInputData: INodeExecutionData[],
|
||||
protected readonly inputData: ITaskDataConnections,
|
||||
protected readonly executeData: IExecuteData,
|
||||
protected readonly abortSignal?: AbortSignal,
|
||||
) {
|
||||
super(workflow, node, additionalData, mode, runExecutionData, runIndex);
|
||||
}
|
||||
|
||||
getExecutionCancelSignal() {
|
||||
return this.abortSignal;
|
||||
}
|
||||
|
||||
onExecutionCancellation(handler: () => unknown) {
|
||||
const fn = () => {
|
||||
this.abortSignal?.removeEventListener('abort', fn);
|
||||
handler();
|
||||
};
|
||||
this.abortSignal?.addEventListener('abort', fn);
|
||||
}
|
||||
|
||||
getExecuteData() {
|
||||
return this.executeData;
|
||||
}
|
||||
|
||||
setMetadata(metadata: ITaskMetadata): void {
|
||||
this.executeData.metadata = {
|
||||
...(this.executeData.metadata ?? {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
getContext(type: ContextType): IContextObject {
|
||||
return NodeHelpers.getContext(this.runExecutionData, type, this.node);
|
||||
}
|
||||
|
||||
/** Returns if execution should be continued even if there was an error */
|
||||
continueOnFail(): boolean {
|
||||
const onError = get(this.node, 'onError', undefined);
|
||||
|
||||
if (onError === undefined) {
|
||||
return get(this.node, 'continueOnFail', false);
|
||||
}
|
||||
|
||||
return ['continueRegularOutput', 'continueErrorOutput'].includes(onError);
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(
|
||||
type: string,
|
||||
itemIndex: number,
|
||||
) {
|
||||
return await this._getCredentials<T>(
|
||||
type,
|
||||
this.executeData,
|
||||
this.connectionInputData,
|
||||
itemIndex,
|
||||
);
|
||||
}
|
||||
|
||||
async putExecutionToWait(waitTill: Date): Promise<void> {
|
||||
this.runExecutionData.waitTill = waitTill;
|
||||
if (this.additionalData.setExecutionStatus) {
|
||||
this.additionalData.setExecutionStatus('waiting');
|
||||
}
|
||||
}
|
||||
|
||||
async executeWorkflow(
|
||||
workflowInfo: IExecuteWorkflowInfo,
|
||||
inputData?: INodeExecutionData[],
|
||||
parentCallbackManager?: CallbackManager,
|
||||
options?: {
|
||||
doNotWaitToFinish?: boolean;
|
||||
parentExecution?: RelatedExecution;
|
||||
},
|
||||
): Promise<ExecuteWorkflowData> {
|
||||
const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, {
|
||||
...options,
|
||||
parentWorkflowId: this.workflow.id,
|
||||
inputData,
|
||||
parentWorkflowSettings: this.workflow.settings,
|
||||
node: this.node,
|
||||
parentCallbackManager,
|
||||
});
|
||||
|
||||
// If a sub-workflow execution goes into the waiting state
|
||||
if (result.waitTill) {
|
||||
// then put the parent workflow execution also into the waiting state,
|
||||
// but do not use the sub-workflow `waitTill` to avoid WaitTracker resuming the parent execution at the same time as the sub-workflow
|
||||
await this.putExecutionToWait(WAIT_INDEFINITELY);
|
||||
}
|
||||
|
||||
const data = await this.binaryDataService.duplicateBinaryData(
|
||||
this.workflow.id,
|
||||
this.additionalData.executionId!,
|
||||
result.data,
|
||||
);
|
||||
return { ...result, data };
|
||||
}
|
||||
|
||||
protected getInputItems(inputIndex: number, connectionType: NodeConnectionType) {
|
||||
const inputData = this.inputData[connectionType];
|
||||
if (inputData.length < inputIndex) {
|
||||
throw new ApplicationError('Could not get input with given index', {
|
||||
extra: { inputIndex, connectionType },
|
||||
});
|
||||
}
|
||||
|
||||
const allItems = inputData[inputIndex] as INodeExecutionData[] | null | undefined;
|
||||
if (allItems === null) {
|
||||
throw new ApplicationError('Input index was not set', {
|
||||
extra: { inputIndex, connectionType },
|
||||
});
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
getInputSourceData(inputIndex = 0, connectionType = NodeConnectionType.Main): ISourceData {
|
||||
if (this.executeData?.source === null) {
|
||||
// Should never happen as n8n sets it automatically
|
||||
throw new ApplicationError('Source data is missing');
|
||||
}
|
||||
return this.executeData.source[connectionType][inputIndex]!;
|
||||
}
|
||||
|
||||
getWorkflowDataProxy(itemIndex: number): IWorkflowDataProxyData {
|
||||
return new WorkflowDataProxy(
|
||||
this.workflow,
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
itemIndex,
|
||||
this.node.name,
|
||||
this.connectionInputData,
|
||||
{},
|
||||
this.mode,
|
||||
this.additionalKeys,
|
||||
this.executeData,
|
||||
).getDataProxy();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sendMessageToUI(...args: any[]): void {
|
||||
if (this.mode !== 'manual') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (this.additionalData.sendDataToUI) {
|
||||
args = args.map((arg) => {
|
||||
// prevent invalid dates from being logged as null
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
|
||||
if (arg.isLuxonDateTime && arg.invalidReason) return { ...arg };
|
||||
|
||||
// log valid dates in human readable format, as in browser
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
|
||||
if (arg.isLuxonDateTime) return new Date(arg.ts).toString();
|
||||
if (arg instanceof Date) return arg.toString();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return arg;
|
||||
});
|
||||
|
||||
this.additionalData.sendDataToUI('sendConsoleMessage', {
|
||||
source: `[Node: "${this.node.name}"]`,
|
||||
messages: args,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
this.logger.warn(`There was a problem sending message to UI: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logAiEvent(eventName: AiEvent, msg: string) {
|
||||
return this.additionalData.logAiEvent(eventName, {
|
||||
executionId: this.additionalData.executionId ?? 'unsaved-execution',
|
||||
nodeName: this.node.name,
|
||||
workflowName: this.workflow.name ?? 'Unnamed workflow',
|
||||
nodeType: this.node.type,
|
||||
workflowId: this.workflow.id ?? 'unsaved-workflow',
|
||||
msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
AINodeConnectionType,
|
||||
CallbackManager,
|
||||
CloseFunction,
|
||||
IExecuteData,
|
||||
IExecuteFunctions,
|
||||
IExecuteResponsePromiseData,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IRunExecutionData,
|
||||
ITaskDataConnections,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Result,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
createDeferredPromise,
|
||||
createEnvProviderState,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import {
|
||||
returnJsonArray,
|
||||
copyInputItems,
|
||||
normalizeItems,
|
||||
constructExecutionMetaData,
|
||||
assertBinaryData,
|
||||
getBinaryDataBuffer,
|
||||
copyBinaryFile,
|
||||
getRequestHelperFunctions,
|
||||
getBinaryHelperFunctions,
|
||||
getSSHTunnelFunctions,
|
||||
getFileSystemHelperFunctions,
|
||||
getCheckProcessedHelperFunctions,
|
||||
detectBinaryEncoding,
|
||||
} from '@/node-execute-functions';
|
||||
|
||||
import { BaseExecuteContext } from './base-execute-context';
|
||||
import { getInputConnectionData } from './utils/get-input-connection-data';
|
||||
|
||||
export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions {
|
||||
readonly helpers: IExecuteFunctions['helpers'];
|
||||
|
||||
readonly nodeHelpers: IExecuteFunctions['nodeHelpers'];
|
||||
|
||||
readonly getNodeParameter: IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
inputData: ITaskDataConnections,
|
||||
executeData: IExecuteData,
|
||||
private readonly closeFunctions: CloseFunction[],
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
super(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
copyInputItems,
|
||||
normalizeItems,
|
||||
constructExecutionMetaData,
|
||||
...getRequestHelperFunctions(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
connectionInputData,
|
||||
),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getSSHTunnelFunctions(),
|
||||
...getFileSystemHelperFunctions(node),
|
||||
...getCheckProcessedHelperFunctions(workflow, node),
|
||||
|
||||
assertBinaryData: (itemIndex, propertyName) =>
|
||||
assertBinaryData(inputData, node, itemIndex, propertyName, 0),
|
||||
getBinaryDataBuffer: async (itemIndex, propertyName) =>
|
||||
await getBinaryDataBuffer(inputData, itemIndex, propertyName, 0),
|
||||
detectBinaryEncoding: (buffer: Buffer) => detectBinaryEncoding(buffer),
|
||||
};
|
||||
|
||||
this.nodeHelpers = {
|
||||
copyBinaryFile: async (filePath, fileName, mimeType) =>
|
||||
await copyBinaryFile(
|
||||
this.workflow.id,
|
||||
this.additionalData.executionId!,
|
||||
filePath,
|
||||
fileName,
|
||||
mimeType,
|
||||
),
|
||||
};
|
||||
|
||||
this.getNodeParameter = ((
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackValue?: any,
|
||||
options?: IGetNodeParameterOptions,
|
||||
) =>
|
||||
this._getNodeParameter(
|
||||
parameterName,
|
||||
itemIndex,
|
||||
fallbackValue,
|
||||
options,
|
||||
)) as IExecuteFunctions['getNodeParameter'];
|
||||
}
|
||||
|
||||
async startJob<T = unknown, E = unknown>(
|
||||
jobType: string,
|
||||
settings: unknown,
|
||||
itemIndex: number,
|
||||
): Promise<Result<T, E>> {
|
||||
return await this.additionalData.startRunnerTask<T, E>(
|
||||
this.additionalData,
|
||||
jobType,
|
||||
settings,
|
||||
this,
|
||||
this.inputData,
|
||||
this.node,
|
||||
this.workflow,
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
itemIndex,
|
||||
this.node.name,
|
||||
this.connectionInputData,
|
||||
{},
|
||||
this.mode,
|
||||
createEnvProviderState(),
|
||||
this.executeData,
|
||||
);
|
||||
}
|
||||
|
||||
async getInputConnectionData(
|
||||
connectionType: AINodeConnectionType,
|
||||
itemIndex: number,
|
||||
): Promise<unknown> {
|
||||
return await getInputConnectionData.call(
|
||||
this,
|
||||
this.workflow,
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
this.connectionInputData,
|
||||
this.inputData,
|
||||
this.additionalData,
|
||||
this.executeData,
|
||||
this.mode,
|
||||
this.closeFunctions,
|
||||
connectionType,
|
||||
itemIndex,
|
||||
this.abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
getInputData(inputIndex = 0, connectionType = NodeConnectionType.Main) {
|
||||
if (!this.inputData.hasOwnProperty(connectionType)) {
|
||||
// Return empty array because else it would throw error when nothing is connected to input
|
||||
return [];
|
||||
}
|
||||
return super.getInputItems(inputIndex, connectionType) ?? [];
|
||||
}
|
||||
|
||||
logNodeOutput(...args: unknown[]): void {
|
||||
if (this.mode === 'manual') {
|
||||
this.sendMessageToUI(...args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.CODE_ENABLE_STDOUT === 'true') {
|
||||
console.log(`[Workflow "${this.getWorkflow().id}"][Node "${this.node.name}"]`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
async sendResponse(response: IExecuteResponsePromiseData): Promise<void> {
|
||||
await this.additionalData.hooks?.executeHookFunctions('sendResponse', [response]);
|
||||
}
|
||||
|
||||
/** @deprecated use ISupplyDataFunctions.addInputData */
|
||||
addInputData(): { index: number } {
|
||||
throw new ApplicationError('addInputData should not be called on IExecuteFunctions');
|
||||
}
|
||||
|
||||
/** @deprecated use ISupplyDataFunctions.addOutputData */
|
||||
addOutputData(): void {
|
||||
throw new ApplicationError('addOutputData should not be called on IExecuteFunctions');
|
||||
}
|
||||
|
||||
getParentCallbackManager(): CallbackManager | undefined {
|
||||
return this.additionalData.parentCallbackManager;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IRunExecutionData,
|
||||
IExecuteSingleFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ITaskDataConnections,
|
||||
IExecuteData,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, createDeferredPromise, NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import {
|
||||
assertBinaryData,
|
||||
detectBinaryEncoding,
|
||||
getBinaryDataBuffer,
|
||||
getBinaryHelperFunctions,
|
||||
getRequestHelperFunctions,
|
||||
returnJsonArray,
|
||||
} from '@/node-execute-functions';
|
||||
|
||||
import { BaseExecuteContext } from './base-execute-context';
|
||||
|
||||
export class ExecuteSingleContext extends BaseExecuteContext implements IExecuteSingleFunctions {
|
||||
readonly helpers: IExecuteSingleFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
inputData: ITaskDataConnections,
|
||||
private readonly itemIndex: number,
|
||||
executeData: IExecuteData,
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
super(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
...getRequestHelperFunctions(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
connectionInputData,
|
||||
),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
|
||||
assertBinaryData: (propertyName, inputIndex = 0) =>
|
||||
assertBinaryData(inputData, node, itemIndex, propertyName, inputIndex),
|
||||
getBinaryDataBuffer: async (propertyName, inputIndex = 0) =>
|
||||
await getBinaryDataBuffer(inputData, itemIndex, propertyName, inputIndex),
|
||||
detectBinaryEncoding: (buffer) => detectBinaryEncoding(buffer),
|
||||
};
|
||||
}
|
||||
|
||||
evaluateExpression(expression: string, itemIndex: number = this.itemIndex) {
|
||||
return super.evaluateExpression(expression, itemIndex);
|
||||
}
|
||||
|
||||
getInputData(inputIndex = 0, connectionType = NodeConnectionType.Main) {
|
||||
if (!this.inputData.hasOwnProperty(connectionType)) {
|
||||
// Return empty array because else it would throw error when nothing is connected to input
|
||||
return { json: {} };
|
||||
}
|
||||
|
||||
const allItems = super.getInputItems(inputIndex, connectionType);
|
||||
|
||||
const data = allItems?.[this.itemIndex];
|
||||
if (data === undefined) {
|
||||
throw new ApplicationError('Value of input with given index was not set', {
|
||||
extra: { inputIndex, connectionType, itemIndex: this.itemIndex },
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
getItemIndex() {
|
||||
return this.itemIndex;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getNodeParameter(parameterName: string, fallbackValue?: any, options?: IGetNodeParameterOptions) {
|
||||
return this._getNodeParameter(parameterName, this.itemIndex, fallbackValue, options);
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await super.getCredentials<T>(type, this.itemIndex);
|
||||
}
|
||||
|
||||
getWorkflowDataProxy() {
|
||||
return super.getWorkflowDataProxy(this.itemIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
INode,
|
||||
IHookFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
IWebhookData,
|
||||
WebhookType,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import {
|
||||
getNodeWebhookUrl,
|
||||
getRequestHelperFunctions,
|
||||
getWebhookDescription,
|
||||
} from '@/node-execute-functions';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
|
||||
export class HookContext extends NodeExecutionContext implements IHookFunctions {
|
||||
readonly helpers: IHookFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
private readonly activation: WorkflowActivateMode,
|
||||
private readonly webhookData?: IWebhookData,
|
||||
) {
|
||||
super(workflow, node, additionalData, mode);
|
||||
|
||||
this.helpers = getRequestHelperFunctions(workflow, node, additionalData);
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
|
||||
getNodeWebhookUrl(name: WebhookType): string | undefined {
|
||||
return getNodeWebhookUrl(
|
||||
name,
|
||||
this.workflow,
|
||||
this.node,
|
||||
this.additionalData,
|
||||
this.mode,
|
||||
this.additionalKeys,
|
||||
this.webhookData?.isTest,
|
||||
);
|
||||
}
|
||||
|
||||
getWebhookName(): string {
|
||||
if (this.webhookData === undefined) {
|
||||
throw new ApplicationError('Only supported in webhook functions');
|
||||
}
|
||||
return this.webhookData.webhookDescription.name;
|
||||
}
|
||||
|
||||
getWebhookDescription(name: WebhookType) {
|
||||
return getWebhookDescription(name, this.workflow, this.node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
export { ExecuteContext } from './execute-context';
|
||||
export { ExecuteSingleContext } from './execute-single-context';
|
||||
export { HookContext } from './hook-context';
|
||||
export { LoadOptionsContext } from './load-options-context';
|
||||
export { LocalLoadOptionsContext } from './local-load-options-context';
|
||||
export { PollContext } from './poll-context';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
export { SupplyDataContext } from './supply-data-context';
|
||||
export { TriggerContext } from './trigger-context';
|
||||
export { WebhookContext } from './webhook-context';
|
||||
|
||||
export { getAdditionalKeys } from './utils/get-additional-keys';
|
||||
@@ -0,0 +1,71 @@
|
||||
import { get } from 'lodash';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
ILoadOptionsFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeParameterValueType,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { getRequestHelperFunctions, getSSHTunnelFunctions } from '@/node-execute-functions';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
import { extractValue } from './utils/extract-value';
|
||||
|
||||
export class LoadOptionsContext extends NodeExecutionContext implements ILoadOptionsFunctions {
|
||||
readonly helpers: ILoadOptionsFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
private readonly path: string,
|
||||
) {
|
||||
super(workflow, node, additionalData, 'internal');
|
||||
|
||||
this.helpers = {
|
||||
...getSSHTunnelFunctions(),
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
};
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
|
||||
getCurrentNodeParameter(
|
||||
parameterPath: string,
|
||||
options?: IGetNodeParameterOptions,
|
||||
): NodeParameterValueType | object | undefined {
|
||||
const nodeParameters = this.additionalData.currentNodeParameters;
|
||||
|
||||
if (parameterPath.charAt(0) === '&') {
|
||||
parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
|
||||
}
|
||||
|
||||
let returnData = get(nodeParameters, parameterPath);
|
||||
|
||||
// This is outside the try/catch because it throws errors with proper messages
|
||||
if (options?.extractValue) {
|
||||
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
|
||||
this.node.type,
|
||||
this.node.typeVersion,
|
||||
);
|
||||
returnData = extractValue(
|
||||
returnData,
|
||||
parameterPath,
|
||||
this.node,
|
||||
nodeType,
|
||||
) as NodeParameterValueType;
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
getCurrentNodeParameters() {
|
||||
return this.additionalData.currentNodeParameters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import lodash from 'lodash';
|
||||
import { ApplicationError, Workflow } from 'n8n-workflow';
|
||||
import type {
|
||||
INodeParameterResourceLocator,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeParameterValueType,
|
||||
ILocalLoadOptionsFunctions,
|
||||
IWorkflowLoader,
|
||||
IWorkflowNodeContext,
|
||||
INodeTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { LoadWorkflowNodeContext } from './workflow-node-context';
|
||||
|
||||
export class LocalLoadOptionsContext implements ILocalLoadOptionsFunctions {
|
||||
constructor(
|
||||
private nodeTypes: INodeTypes,
|
||||
private additionalData: IWorkflowExecuteAdditionalData,
|
||||
private path: string,
|
||||
private workflowLoader: IWorkflowLoader,
|
||||
) {}
|
||||
|
||||
async getWorkflowNodeContext(nodeType: string): Promise<IWorkflowNodeContext | null> {
|
||||
const { value: workflowId } = this.getCurrentNodeParameter(
|
||||
'workflowId',
|
||||
) as INodeParameterResourceLocator;
|
||||
|
||||
if (typeof workflowId !== 'string' || !workflowId) {
|
||||
throw new ApplicationError(`No workflowId parameter defined on node of type "${nodeType}"!`);
|
||||
}
|
||||
|
||||
const dbWorkflow = await this.workflowLoader.get(workflowId);
|
||||
|
||||
const selectedWorkflowNode = dbWorkflow.nodes.find((node) => node.type === nodeType);
|
||||
|
||||
if (selectedWorkflowNode) {
|
||||
const selectedSingleNodeWorkflow = new Workflow({
|
||||
nodes: [selectedWorkflowNode],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes: this.nodeTypes,
|
||||
});
|
||||
|
||||
const workflowAdditionalData = {
|
||||
...this.additionalData,
|
||||
currentNodeParameters: selectedWorkflowNode.parameters,
|
||||
};
|
||||
|
||||
return new LoadWorkflowNodeContext(
|
||||
selectedSingleNodeWorkflow,
|
||||
selectedWorkflowNode,
|
||||
workflowAdditionalData,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getCurrentNodeParameter(parameterPath: string): NodeParameterValueType | object | undefined {
|
||||
const nodeParameters = this.additionalData.currentNodeParameters;
|
||||
|
||||
if (parameterPath.startsWith('&')) {
|
||||
parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
|
||||
}
|
||||
|
||||
const returnData = lodash.get(nodeParameters, parameterPath);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { get } from 'lodash';
|
||||
import type {
|
||||
FunctionsBase,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsExpressionResolveValues,
|
||||
IExecuteData,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeCredentialDescription,
|
||||
INodeCredentialsDetails,
|
||||
INodeExecutionData,
|
||||
INodeInputConfiguration,
|
||||
INodeOutputConfiguration,
|
||||
IRunExecutionData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeConnectionType,
|
||||
NodeParameterValueType,
|
||||
NodeTypeAndVersion,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
deepCopy,
|
||||
ExpressionError,
|
||||
NodeHelpers,
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/constants';
|
||||
import { Memoized } from '@/decorators';
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
import { Logger } from '@/logging/logger';
|
||||
|
||||
import { cleanupParameterData } from './utils/cleanup-parameter-data';
|
||||
import { ensureType } from './utils/ensure-type';
|
||||
import { extractValue } from './utils/extract-value';
|
||||
import { getAdditionalKeys } from './utils/get-additional-keys';
|
||||
import { validateValueAgainstSchema } from './utils/validate-value-against-schema';
|
||||
|
||||
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
|
||||
protected readonly instanceSettings = Container.get(InstanceSettings);
|
||||
|
||||
constructor(
|
||||
protected readonly workflow: Workflow,
|
||||
protected readonly node: INode,
|
||||
protected readonly additionalData: IWorkflowExecuteAdditionalData,
|
||||
protected readonly mode: WorkflowExecuteMode,
|
||||
protected readonly runExecutionData: IRunExecutionData | null = null,
|
||||
protected readonly runIndex = 0,
|
||||
protected readonly connectionInputData: INodeExecutionData[] = [],
|
||||
protected readonly executeData?: IExecuteData,
|
||||
) {}
|
||||
|
||||
@Memoized
|
||||
get logger() {
|
||||
return Container.get(Logger);
|
||||
}
|
||||
|
||||
getExecutionId() {
|
||||
return this.additionalData.executionId!;
|
||||
}
|
||||
|
||||
getNode(): INode {
|
||||
return deepCopy(this.node);
|
||||
}
|
||||
|
||||
getWorkflow() {
|
||||
const { id, name, active } = this.workflow;
|
||||
return { id, name, active };
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
getWorkflowStaticData(type: string) {
|
||||
return this.workflow.getStaticData(type, this.node);
|
||||
}
|
||||
|
||||
getChildNodes(nodeName: string) {
|
||||
const output: NodeTypeAndVersion[] = [];
|
||||
const nodeNames = this.workflow.getChildNodes(nodeName);
|
||||
|
||||
for (const n of nodeNames) {
|
||||
const node = this.workflow.nodes[n];
|
||||
output.push({
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
typeVersion: node.typeVersion,
|
||||
disabled: node.disabled ?? false,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
getParentNodes(nodeName: string) {
|
||||
const output: NodeTypeAndVersion[] = [];
|
||||
const nodeNames = this.workflow.getParentNodes(nodeName);
|
||||
|
||||
for (const n of nodeNames) {
|
||||
const node = this.workflow.nodes[n];
|
||||
output.push({
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
typeVersion: node.typeVersion,
|
||||
disabled: node.disabled ?? false,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@Memoized
|
||||
get nodeType() {
|
||||
const { type, typeVersion } = this.node;
|
||||
return this.workflow.nodeTypes.getByNameAndVersion(type, typeVersion);
|
||||
}
|
||||
|
||||
@Memoized
|
||||
get nodeInputs() {
|
||||
return NodeHelpers.getNodeInputs(this.workflow, this.node, this.nodeType.description).map(
|
||||
(input) => (typeof input === 'string' ? { type: input } : input),
|
||||
);
|
||||
}
|
||||
|
||||
getNodeInputs(): INodeInputConfiguration[] {
|
||||
return this.nodeInputs;
|
||||
}
|
||||
|
||||
@Memoized
|
||||
get nodeOutputs() {
|
||||
return NodeHelpers.getNodeOutputs(this.workflow, this.node, this.nodeType.description).map(
|
||||
(output) => (typeof output === 'string' ? { type: output } : output),
|
||||
);
|
||||
}
|
||||
|
||||
getConnectedNodes(connectionType: NodeConnectionType): INode[] {
|
||||
return this.workflow
|
||||
.getParentNodes(this.node.name, connectionType, 1)
|
||||
.map((nodeName) => this.workflow.getNode(nodeName))
|
||||
.filter((node) => !!node)
|
||||
.filter((node) => node.disabled !== true);
|
||||
}
|
||||
|
||||
getNodeOutputs(): INodeOutputConfiguration[] {
|
||||
return this.nodeOutputs;
|
||||
}
|
||||
|
||||
getKnownNodeTypes() {
|
||||
return this.workflow.nodeTypes.getKnownTypes();
|
||||
}
|
||||
|
||||
getRestApiUrl() {
|
||||
return this.additionalData.restApiUrl;
|
||||
}
|
||||
|
||||
getInstanceBaseUrl() {
|
||||
return this.additionalData.instanceBaseUrl;
|
||||
}
|
||||
|
||||
getInstanceId() {
|
||||
return this.instanceSettings.instanceId;
|
||||
}
|
||||
|
||||
getTimezone() {
|
||||
return this.workflow.timezone;
|
||||
}
|
||||
|
||||
getCredentialsProperties(type: string) {
|
||||
return this.additionalData.credentialsHelper.getCredentialsProperties(type);
|
||||
}
|
||||
|
||||
/** Returns the requested decrypted credentials if the node has access to them */
|
||||
protected async _getCredentials<T extends object = ICredentialDataDecryptedObject>(
|
||||
type: string,
|
||||
executeData?: IExecuteData,
|
||||
connectionInputData?: INodeExecutionData[],
|
||||
itemIndex?: number,
|
||||
): Promise<T> {
|
||||
const { workflow, node, additionalData, mode, runExecutionData, runIndex } = this;
|
||||
// Get the NodeType as it has the information if the credentials are required
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
// Hardcode for now for security reasons that only a single node can access
|
||||
// all credentials
|
||||
const fullAccess = [HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE].includes(node.type);
|
||||
|
||||
let nodeCredentialDescription: INodeCredentialDescription | undefined;
|
||||
if (!fullAccess) {
|
||||
if (nodeType.description.credentials === undefined) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Node type "${node.type}" does not have any credentials defined`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
|
||||
nodeCredentialDescription = nodeType.description.credentials.find(
|
||||
(credentialTypeDescription) => credentialTypeDescription.name === type,
|
||||
);
|
||||
if (nodeCredentialDescription === undefined) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Node type "${node.type}" does not have any credentials of type "${type}" defined`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!NodeHelpers.displayParameter(
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
additionalData.currentNodeParameters || node.parameters,
|
||||
nodeCredentialDescription,
|
||||
node,
|
||||
node.parameters,
|
||||
)
|
||||
) {
|
||||
// Credentials should not be displayed even if they would be defined
|
||||
throw new NodeOperationError(node, 'Credentials not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if node has any credentials defined
|
||||
if (!fullAccess && !node.credentials?.[type]) {
|
||||
// If none are defined check if the credentials are required or not
|
||||
|
||||
if (nodeCredentialDescription?.required === true) {
|
||||
// Credentials are required so error
|
||||
if (!node.credentials) {
|
||||
throw new NodeOperationError(node, 'Node does not have any credentials set', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
if (!node.credentials[type]) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Node does not have any credentials set for "${type}"`,
|
||||
{
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Credentials are not required
|
||||
throw new NodeOperationError(node, 'Node does not require credentials');
|
||||
}
|
||||
}
|
||||
|
||||
if (fullAccess && !node.credentials?.[type]) {
|
||||
// Make sure that fullAccess nodes still behave like before that if they
|
||||
// request access to credentials that are currently not set it returns undefined
|
||||
throw new NodeOperationError(node, 'Credentials not found');
|
||||
}
|
||||
|
||||
let expressionResolveValues: ICredentialsExpressionResolveValues | undefined;
|
||||
if (connectionInputData && runExecutionData && runIndex !== undefined) {
|
||||
expressionResolveValues = {
|
||||
connectionInputData,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
itemIndex: itemIndex || 0,
|
||||
node,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
workflow,
|
||||
} as ICredentialsExpressionResolveValues;
|
||||
}
|
||||
|
||||
const nodeCredentials = node.credentials
|
||||
? node.credentials[type]
|
||||
: ({} as INodeCredentialsDetails);
|
||||
|
||||
// TODO: solve using credentials via expression
|
||||
// if (name.charAt(0) === '=') {
|
||||
// // If the credential name is an expression resolve it
|
||||
// const additionalKeys = getAdditionalKeys(additionalData, mode);
|
||||
// name = workflow.expression.getParameterValue(
|
||||
// name,
|
||||
// runExecutionData || null,
|
||||
// runIndex || 0,
|
||||
// itemIndex || 0,
|
||||
// node.name,
|
||||
// connectionInputData || [],
|
||||
// mode,
|
||||
// additionalKeys,
|
||||
// ) as string;
|
||||
// }
|
||||
|
||||
const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted(
|
||||
additionalData,
|
||||
nodeCredentials,
|
||||
type,
|
||||
mode,
|
||||
executeData,
|
||||
false,
|
||||
expressionResolveValues,
|
||||
);
|
||||
|
||||
return decryptedDataObject as T;
|
||||
}
|
||||
|
||||
@Memoized
|
||||
protected get additionalKeys() {
|
||||
return getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
|
||||
}
|
||||
|
||||
/** Returns the requested resolved (all expressions replaced) node parameters. */
|
||||
getNodeParameter(
|
||||
parameterName: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackValue?: any,
|
||||
options?: IGetNodeParameterOptions,
|
||||
): NodeParameterValueType | object {
|
||||
const itemIndex = 0;
|
||||
return this._getNodeParameter(parameterName, itemIndex, fallbackValue, options);
|
||||
}
|
||||
|
||||
protected _getNodeParameter(
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackValue?: any,
|
||||
options?: IGetNodeParameterOptions,
|
||||
): NodeParameterValueType | object {
|
||||
const { workflow, node, mode, runExecutionData, runIndex, connectionInputData, executeData } =
|
||||
this;
|
||||
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const value = get(node.parameters, parameterName, fallbackValue);
|
||||
|
||||
if (value === undefined) {
|
||||
throw new ApplicationError('Could not get parameter', { extra: { parameterName } });
|
||||
}
|
||||
|
||||
if (options?.rawExpressions) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return value;
|
||||
}
|
||||
|
||||
const { additionalKeys } = this;
|
||||
|
||||
let returnData;
|
||||
|
||||
try {
|
||||
returnData = workflow.expression.getParameterValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
value,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
itemIndex,
|
||||
node.name,
|
||||
connectionInputData,
|
||||
mode,
|
||||
additionalKeys,
|
||||
executeData,
|
||||
false,
|
||||
{},
|
||||
options?.contextNode?.name,
|
||||
);
|
||||
cleanupParameterData(returnData);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof ExpressionError &&
|
||||
node.continueOnFail &&
|
||||
node.type === 'n8n-nodes-base.set'
|
||||
) {
|
||||
// https://linear.app/n8n/issue/PAY-684
|
||||
returnData = [{ name: undefined, value: undefined }];
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (e.context) e.context.parameter = parameterName;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
e.cause = value;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// This is outside the try/catch because it throws errors with proper messages
|
||||
if (options?.extractValue) {
|
||||
returnData = extractValue(returnData, parameterName, node, nodeType, itemIndex);
|
||||
}
|
||||
|
||||
// Make sure parameter value is the type specified in the ensureType option, if needed convert it
|
||||
if (options?.ensureType) {
|
||||
returnData = ensureType(options.ensureType, returnData, parameterName, {
|
||||
itemIndex,
|
||||
runIndex,
|
||||
nodeCause: node.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate parameter value if it has a schema defined(RMC) or validateType defined
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
returnData = validateValueAgainstSchema(
|
||||
node,
|
||||
nodeType,
|
||||
returnData,
|
||||
parameterName,
|
||||
runIndex,
|
||||
itemIndex,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return returnData;
|
||||
}
|
||||
|
||||
evaluateExpression(expression: string, itemIndex: number = 0) {
|
||||
return this.workflow.expression.resolveSimpleParameterValue(
|
||||
`=${expression}`,
|
||||
{},
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
itemIndex,
|
||||
this.node.name,
|
||||
this.connectionInputData,
|
||||
this.mode,
|
||||
this.additionalKeys,
|
||||
this.executeData,
|
||||
);
|
||||
}
|
||||
|
||||
async prepareOutputData(outputData: INodeExecutionData[]) {
|
||||
return [outputData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
INode,
|
||||
IPollFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import {
|
||||
getBinaryHelperFunctions,
|
||||
getRequestHelperFunctions,
|
||||
getSchedulingFunctions,
|
||||
returnJsonArray,
|
||||
} from '@/node-execute-functions';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
|
||||
const throwOnEmit = () => {
|
||||
throw new ApplicationError('Overwrite PollContext.__emit function');
|
||||
};
|
||||
|
||||
const throwOnEmitError = () => {
|
||||
throw new ApplicationError('Overwrite PollContext.__emitError function');
|
||||
};
|
||||
|
||||
export class PollContext extends NodeExecutionContext implements IPollFunctions {
|
||||
readonly helpers: IPollFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
private readonly activation: WorkflowActivateMode,
|
||||
readonly __emit: IPollFunctions['__emit'] = throwOnEmit,
|
||||
readonly __emitError: IPollFunctions['__emitError'] = throwOnEmitError,
|
||||
) {
|
||||
super(workflow, node, additionalData, mode);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getSchedulingFunctions(workflow),
|
||||
};
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
AINodeConnectionType,
|
||||
CloseFunction,
|
||||
ExecutionBaseError,
|
||||
IExecuteData,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IRunExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
ITaskData,
|
||||
ITaskDataConnections,
|
||||
ITaskMetadata,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeConnectionType,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { createDeferredPromise } from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import {
|
||||
assertBinaryData,
|
||||
constructExecutionMetaData,
|
||||
copyInputItems,
|
||||
detectBinaryEncoding,
|
||||
getBinaryDataBuffer,
|
||||
getBinaryHelperFunctions,
|
||||
getCheckProcessedHelperFunctions,
|
||||
getFileSystemHelperFunctions,
|
||||
getRequestHelperFunctions,
|
||||
getSSHTunnelFunctions,
|
||||
normalizeItems,
|
||||
returnJsonArray,
|
||||
} from '@/node-execute-functions';
|
||||
|
||||
import { BaseExecuteContext } from './base-execute-context';
|
||||
import { getInputConnectionData } from './utils/get-input-connection-data';
|
||||
|
||||
export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions {
|
||||
readonly helpers: ISupplyDataFunctions['helpers'];
|
||||
|
||||
readonly getNodeParameter: ISupplyDataFunctions['getNodeParameter'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
inputData: ITaskDataConnections,
|
||||
private readonly connectionType: NodeConnectionType,
|
||||
executeData: IExecuteData,
|
||||
private readonly closeFunctions: CloseFunction[],
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
super(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
copyInputItems,
|
||||
...getRequestHelperFunctions(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
connectionInputData,
|
||||
),
|
||||
...getSSHTunnelFunctions(),
|
||||
...getFileSystemHelperFunctions(node),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getCheckProcessedHelperFunctions(workflow, node),
|
||||
assertBinaryData: (itemIndex, propertyName) =>
|
||||
assertBinaryData(inputData, node, itemIndex, propertyName, 0),
|
||||
getBinaryDataBuffer: async (itemIndex, propertyName) =>
|
||||
await getBinaryDataBuffer(inputData, itemIndex, propertyName, 0),
|
||||
detectBinaryEncoding: (buffer: Buffer) => detectBinaryEncoding(buffer),
|
||||
|
||||
returnJsonArray,
|
||||
normalizeItems,
|
||||
constructExecutionMetaData,
|
||||
};
|
||||
|
||||
this.getNodeParameter = ((
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackValue?: any,
|
||||
options?: IGetNodeParameterOptions,
|
||||
) =>
|
||||
this._getNodeParameter(
|
||||
parameterName,
|
||||
itemIndex,
|
||||
fallbackValue,
|
||||
options,
|
||||
)) as ISupplyDataFunctions['getNodeParameter'];
|
||||
}
|
||||
|
||||
async getInputConnectionData(
|
||||
connectionType: AINodeConnectionType,
|
||||
itemIndex: number,
|
||||
): Promise<unknown> {
|
||||
return await getInputConnectionData.call(
|
||||
this,
|
||||
this.workflow,
|
||||
this.runExecutionData,
|
||||
this.runIndex,
|
||||
this.connectionInputData,
|
||||
this.inputData,
|
||||
this.additionalData,
|
||||
this.executeData,
|
||||
this.mode,
|
||||
this.closeFunctions,
|
||||
connectionType,
|
||||
itemIndex,
|
||||
this.abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
getInputData(inputIndex = 0, connectionType = this.connectionType) {
|
||||
if (!this.inputData.hasOwnProperty(connectionType)) {
|
||||
// Return empty array because else it would throw error when nothing is connected to input
|
||||
return [];
|
||||
}
|
||||
return super.getInputItems(inputIndex, connectionType) ?? [];
|
||||
}
|
||||
|
||||
/** @deprecated create a context object with inputData for every runIndex */
|
||||
addInputData(
|
||||
connectionType: AINodeConnectionType,
|
||||
data: INodeExecutionData[][],
|
||||
): { index: number } {
|
||||
const nodeName = this.node.name;
|
||||
let currentNodeRunIndex = 0;
|
||||
if (this.runExecutionData.resultData.runData.hasOwnProperty(nodeName)) {
|
||||
currentNodeRunIndex = this.runExecutionData.resultData.runData[nodeName].length;
|
||||
}
|
||||
|
||||
this.addExecutionDataFunctions(
|
||||
'input',
|
||||
data,
|
||||
connectionType,
|
||||
nodeName,
|
||||
currentNodeRunIndex,
|
||||
).catch((error) => {
|
||||
this.logger.warn(
|
||||
`There was a problem logging input data of node "${nodeName}": ${
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
error.message
|
||||
}`,
|
||||
);
|
||||
});
|
||||
|
||||
return { index: currentNodeRunIndex };
|
||||
}
|
||||
|
||||
/** @deprecated Switch to WorkflowExecute to store output on runExecutionData.resultData.runData */
|
||||
addOutputData(
|
||||
connectionType: AINodeConnectionType,
|
||||
currentNodeRunIndex: number,
|
||||
data: INodeExecutionData[][] | ExecutionBaseError,
|
||||
metadata?: ITaskMetadata,
|
||||
): void {
|
||||
const nodeName = this.node.name;
|
||||
this.addExecutionDataFunctions(
|
||||
'output',
|
||||
data,
|
||||
connectionType,
|
||||
nodeName,
|
||||
currentNodeRunIndex,
|
||||
metadata,
|
||||
).catch((error) => {
|
||||
this.logger.warn(
|
||||
`There was a problem logging output data of node "${nodeName}": ${
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
error.message
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async addExecutionDataFunctions(
|
||||
type: 'input' | 'output',
|
||||
data: INodeExecutionData[][] | ExecutionBaseError,
|
||||
connectionType: AINodeConnectionType,
|
||||
sourceNodeName: string,
|
||||
currentNodeRunIndex: number,
|
||||
metadata?: ITaskMetadata,
|
||||
): Promise<void> {
|
||||
const {
|
||||
additionalData,
|
||||
runExecutionData,
|
||||
runIndex: sourceNodeRunIndex,
|
||||
node: { name: nodeName },
|
||||
} = this;
|
||||
|
||||
let taskData: ITaskData | undefined;
|
||||
if (type === 'input') {
|
||||
taskData = {
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: 0,
|
||||
executionStatus: 'running',
|
||||
source: [null],
|
||||
};
|
||||
} else {
|
||||
// At the moment we expect that there is always an input sent before the output
|
||||
taskData = get(
|
||||
runExecutionData,
|
||||
['resultData', 'runData', nodeName, currentNodeRunIndex],
|
||||
undefined,
|
||||
);
|
||||
if (taskData === undefined) {
|
||||
return;
|
||||
}
|
||||
taskData.metadata = metadata;
|
||||
}
|
||||
taskData = taskData!;
|
||||
|
||||
if (data instanceof Error) {
|
||||
taskData.executionStatus = 'error';
|
||||
taskData.error = data;
|
||||
} else {
|
||||
if (type === 'output') {
|
||||
taskData.executionStatus = 'success';
|
||||
}
|
||||
taskData.data = {
|
||||
[connectionType]: data,
|
||||
} as ITaskDataConnections;
|
||||
}
|
||||
|
||||
if (type === 'input') {
|
||||
if (!(data instanceof Error)) {
|
||||
this.inputData[connectionType] = data;
|
||||
// TODO: remove inputOverride
|
||||
taskData.inputOverride = {
|
||||
[connectionType]: data,
|
||||
} as ITaskDataConnections;
|
||||
}
|
||||
|
||||
if (!runExecutionData.resultData.runData.hasOwnProperty(nodeName)) {
|
||||
runExecutionData.resultData.runData[nodeName] = [];
|
||||
}
|
||||
|
||||
runExecutionData.resultData.runData[nodeName][currentNodeRunIndex] = taskData;
|
||||
await additionalData.hooks?.executeHookFunctions('nodeExecuteBefore', [nodeName]);
|
||||
} else {
|
||||
// Outputs
|
||||
taskData.executionTime = new Date().getTime() - taskData.startTime;
|
||||
|
||||
await additionalData.hooks?.executeHookFunctions('nodeExecuteAfter', [
|
||||
nodeName,
|
||||
taskData,
|
||||
this.runExecutionData,
|
||||
]);
|
||||
|
||||
if (get(runExecutionData, 'executionData.metadata', undefined) === undefined) {
|
||||
runExecutionData.executionData!.metadata = {};
|
||||
}
|
||||
|
||||
let sourceTaskData = runExecutionData.executionData?.metadata?.[sourceNodeName];
|
||||
|
||||
if (!sourceTaskData) {
|
||||
runExecutionData.executionData!.metadata[sourceNodeName] = [];
|
||||
sourceTaskData = runExecutionData.executionData!.metadata[sourceNodeName];
|
||||
}
|
||||
|
||||
if (!sourceTaskData[sourceNodeRunIndex]) {
|
||||
sourceTaskData[sourceNodeRunIndex] = {
|
||||
subRun: [],
|
||||
};
|
||||
}
|
||||
|
||||
sourceTaskData[sourceNodeRunIndex].subRun!.push({
|
||||
node: nodeName,
|
||||
runIndex: currentNodeRunIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
INode,
|
||||
ITriggerFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import {
|
||||
getBinaryHelperFunctions,
|
||||
getRequestHelperFunctions,
|
||||
getSchedulingFunctions,
|
||||
getSSHTunnelFunctions,
|
||||
returnJsonArray,
|
||||
} from '@/node-execute-functions';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
|
||||
const throwOnEmit = () => {
|
||||
throw new ApplicationError('Overwrite TriggerContext.emit function');
|
||||
};
|
||||
|
||||
const throwOnEmitError = () => {
|
||||
throw new ApplicationError('Overwrite TriggerContext.emitError function');
|
||||
};
|
||||
|
||||
export class TriggerContext extends NodeExecutionContext implements ITriggerFunctions {
|
||||
readonly helpers: ITriggerFunctions['helpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
private readonly activation: WorkflowActivateMode,
|
||||
readonly emit: ITriggerFunctions['emit'] = throwOnEmit,
|
||||
readonly emitError: ITriggerFunctions['emitError'] = throwOnEmitError,
|
||||
) {
|
||||
super(workflow, node, additionalData, mode);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
...getSSHTunnelFunctions(),
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getSchedulingFunctions(workflow),
|
||||
};
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import toPlainObject from 'lodash/toPlainObject';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { NodeParameterValue } from 'n8n-workflow';
|
||||
|
||||
import { cleanupParameterData } from '../cleanup-parameter-data';
|
||||
|
||||
describe('cleanupParameterData', () => {
|
||||
it('should stringify Luxon dates in-place', () => {
|
||||
const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue };
|
||||
expect(typeof input.y).toBe('object');
|
||||
cleanupParameterData(input);
|
||||
expect(typeof input.y).toBe('string');
|
||||
});
|
||||
|
||||
it('should stringify plain Luxon dates in-place', () => {
|
||||
const input = {
|
||||
x: 1,
|
||||
y: toPlainObject(DateTime.now()),
|
||||
};
|
||||
expect(typeof input.y).toBe('object');
|
||||
cleanupParameterData(input);
|
||||
expect(typeof input.y).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle objects with nameless constructors', () => {
|
||||
const input = { x: 1, y: { constructor: {} } as NodeParameterValue };
|
||||
expect(typeof input.y).toBe('object');
|
||||
cleanupParameterData(input);
|
||||
expect(typeof input.y).toBe('object');
|
||||
});
|
||||
|
||||
it('should handle objects without a constructor', () => {
|
||||
const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue };
|
||||
expect(typeof input.y).toBe('object');
|
||||
cleanupParameterData(input);
|
||||
expect(typeof input.y).toBe('object');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INodeType, ISupplyDataFunctions, INode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createNodeAsTool } from '../create-node-as-tool';
|
||||
|
||||
jest.mock('@langchain/core/tools', () => ({
|
||||
DynamicStructuredTool: jest.fn().mockImplementation((config) => ({
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
schema: config.schema,
|
||||
func: config.func,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('createNodeAsTool', () => {
|
||||
const context = mock<ISupplyDataFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
addInputData: jest.fn(),
|
||||
addOutputData: jest.fn(),
|
||||
getNode: jest.fn(),
|
||||
});
|
||||
const handleToolInvocation = jest.fn();
|
||||
const nodeType = mock<INodeType>({
|
||||
description: {
|
||||
name: 'TestNode',
|
||||
description: 'Test node description',
|
||||
},
|
||||
});
|
||||
const node = mock<INode>({ name: 'Test_Node' });
|
||||
const options = { node, nodeType, handleToolInvocation };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(context.addInputData as jest.Mock).mockReturnValue({ index: 0 });
|
||||
(context.getNode as jest.Mock).mockReturnValue(node);
|
||||
(nodeType.execute as jest.Mock).mockResolvedValue([[{ json: { result: 'test' } }]]);
|
||||
|
||||
node.parameters = {
|
||||
param1: "={{$fromAI('param1', 'Test parameter', 'string') }}",
|
||||
param2: 'static value',
|
||||
nestedParam: {
|
||||
subParam: "={{ $fromAI('subparam', 'Nested parameter', 'string') }}",
|
||||
},
|
||||
descriptionType: 'auto',
|
||||
resource: 'testResource',
|
||||
operation: 'testOperation',
|
||||
};
|
||||
});
|
||||
|
||||
describe('Tool Creation and Basic Properties', () => {
|
||||
it('should create a DynamicStructuredTool with correct properties', () => {
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.name).toBe('Test_Node');
|
||||
expect(tool.description).toBe(
|
||||
'Test node description\n Resource: testResource\n Operation: testOperation',
|
||||
);
|
||||
expect(tool.schema).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use toolDescription if provided', () => {
|
||||
node.parameters.descriptionType = 'manual';
|
||||
node.parameters.toolDescription = 'Custom tool description';
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.description).toBe('Custom tool description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schema Creation and Parameter Handling', () => {
|
||||
it('should create a schema based on fromAI arguments in nodeParameters', () => {
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema).toBeDefined();
|
||||
expect(tool.schema.shape).toHaveProperty('param1');
|
||||
expect(tool.schema.shape).toHaveProperty('subparam');
|
||||
expect(tool.schema.shape).not.toHaveProperty('param2');
|
||||
});
|
||||
|
||||
it('should handle fromAI arguments correctly', () => {
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.subparam).toBeInstanceOf(z.ZodString);
|
||||
});
|
||||
|
||||
it('should handle default values correctly', () => {
|
||||
node.parameters = {
|
||||
paramWithDefault:
|
||||
"={{ $fromAI('paramWithDefault', 'Parameter with default', 'string', 'default value') }}",
|
||||
numberWithDefault:
|
||||
"={{ $fromAI('numberWithDefault', 'Number with default', 'number', 42) }}",
|
||||
booleanWithDefault:
|
||||
"={{ $fromAI('booleanWithDefault', 'Boolean with default', 'boolean', true) }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramWithDefault.description).toBe('Parameter with default');
|
||||
expect(tool.schema.shape.numberWithDefault.description).toBe('Number with default');
|
||||
expect(tool.schema.shape.booleanWithDefault.description).toBe('Boolean with default');
|
||||
});
|
||||
|
||||
it('should handle nested parameters correctly', () => {
|
||||
node.parameters = {
|
||||
topLevel: "={{ $fromAI('topLevel', 'Top level parameter', 'string') }}",
|
||||
nested: {
|
||||
level1: "={{ $fromAI('level1', 'Nested level 1', 'string') }}",
|
||||
deeperNested: {
|
||||
level2: "={{ $fromAI('level2', 'Nested level 2', 'number') }}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.topLevel).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.level1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.level2).toBeInstanceOf(z.ZodNumber);
|
||||
});
|
||||
|
||||
it('should handle array parameters correctly', () => {
|
||||
node.parameters = {
|
||||
arrayParam: [
|
||||
"={{ $fromAI('item1', 'First item', 'string') }}",
|
||||
"={{ $fromAI('item2', 'Second item', 'number') }}",
|
||||
],
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.item1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.item2).toBeInstanceOf(z.ZodNumber);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Edge Cases', () => {
|
||||
it('should handle error during node execution', async () => {
|
||||
nodeType.execute = jest.fn().mockRejectedValue(new Error('Execution failed'));
|
||||
const tool = createNodeAsTool(options).response;
|
||||
handleToolInvocation.mockReturnValue('Error during node execution: some random issue.');
|
||||
|
||||
const result = await tool.func({ param1: 'test value' });
|
||||
|
||||
expect(result).toContain('Error during node execution:');
|
||||
});
|
||||
|
||||
it('should throw an error for invalid parameter names', () => {
|
||||
node.parameters.invalidParam = "$fromAI('invalid param', 'Invalid parameter', 'string')";
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow('Parameter key `invalid param` is invalid');
|
||||
});
|
||||
|
||||
it('should throw an error for $fromAI calls with unsupported types', () => {
|
||||
node.parameters = {
|
||||
invalidTypeParam:
|
||||
"={{ $fromAI('invalidType', 'Param with unsupported type', 'unsupportedType') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow('Invalid type: unsupportedType');
|
||||
});
|
||||
|
||||
it('should handle empty parameters and parameters with no fromAI calls', () => {
|
||||
node.parameters = {
|
||||
param1: 'static value 1',
|
||||
param2: 'static value 2',
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Name and Description Handling', () => {
|
||||
it('should accept parameter names with underscores and hyphens', () => {
|
||||
node.parameters = {
|
||||
validName1:
|
||||
"={{ $fromAI('param_name-1', 'Valid name with underscore and hyphen', 'string') }}",
|
||||
validName2: "={{ $fromAI('param_name_2', 'Another valid name', 'number') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape['param_name-1']).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape['param_name-1'].description).toBe(
|
||||
'Valid name with underscore and hyphen',
|
||||
);
|
||||
|
||||
expect(tool.schema.shape.param_name_2).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.param_name_2.description).toBe('Another valid name');
|
||||
});
|
||||
|
||||
it('should throw an error for parameter names with invalid special characters', () => {
|
||||
node.parameters = {
|
||||
invalidNameParam:
|
||||
"={{ $fromAI('param@name!', 'Invalid name with special characters', 'string') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow('Parameter key `param@name!` is invalid');
|
||||
});
|
||||
|
||||
it('should throw an error for empty parameter name', () => {
|
||||
node.parameters = {
|
||||
invalidNameParam: "={{ $fromAI('', 'Invalid name with special characters', 'string') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow(
|
||||
'You must specify a key when using $fromAI()',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle parameter names with exact and exceeding character limits', () => {
|
||||
const longName = 'a'.repeat(64);
|
||||
const tooLongName = 'a'.repeat(65);
|
||||
node.parameters = {
|
||||
longNameParam: `={{ $fromAI('${longName}', 'Param with 64 character name', 'string') }}`,
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape[longName]).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape[longName].description).toBe('Param with 64 character name');
|
||||
|
||||
node.parameters = {
|
||||
tooLongNameParam: `={{ $fromAI('${tooLongName}', 'Param with 65 character name', 'string') }}`,
|
||||
};
|
||||
expect(() => createNodeAsTool(options)).toThrow(
|
||||
`Parameter key \`${tooLongName}\` is invalid`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle $fromAI calls with empty description', () => {
|
||||
node.parameters = {
|
||||
emptyDescriptionParam: "={{ $fromAI('emptyDescription', '', 'number') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.emptyDescription).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.emptyDescription.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error for calls with the same parameter but different descriptions', () => {
|
||||
node.parameters = {
|
||||
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
|
||||
duplicateParam2: "={{ $fromAI('duplicate', 'Second duplicate', 'number') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow(
|
||||
"Duplicate key 'duplicate' found with different description or type",
|
||||
);
|
||||
});
|
||||
it('should throw an error for calls with the same parameter but different types', () => {
|
||||
node.parameters = {
|
||||
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
|
||||
duplicateParam2: "={{ $fromAI('duplicate', 'First duplicate', 'number') }}",
|
||||
};
|
||||
|
||||
expect(() => createNodeAsTool(options)).toThrow(
|
||||
"Duplicate key 'duplicate' found with different description or type",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Parsing Scenarios', () => {
|
||||
it('should correctly parse $fromAI calls with varying spaces, capitalization, and within template literals', () => {
|
||||
node.parameters = {
|
||||
varyingSpacing1: "={{$fromAI('param1','Description1','string')}}",
|
||||
varyingSpacing2: "={{ $fromAI ( 'param2' , 'Description2' , 'number' ) }}",
|
||||
varyingSpacing3: "={{ $FROMai('param3', 'Description3', 'boolean') }}",
|
||||
wrongCapitalization: "={{$fromai('param4','Description4','number')}}",
|
||||
templateLiteralParam:
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
"={{ `Value is: ${$fromAI('templatedParam', 'Templated param description', 'string')}` }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.param1.description).toBe('Description1');
|
||||
|
||||
expect(tool.schema.shape.param2).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.param2.description).toBe('Description2');
|
||||
|
||||
expect(tool.schema.shape.param3).toBeInstanceOf(z.ZodBoolean);
|
||||
expect(tool.schema.shape.param3.description).toBe('Description3');
|
||||
|
||||
expect(tool.schema.shape.param4).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.param4.description).toBe('Description4');
|
||||
|
||||
expect(tool.schema.shape.templatedParam).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.templatedParam.description).toBe('Templated param description');
|
||||
});
|
||||
|
||||
it('should correctly parse multiple $fromAI calls interleaved with regular text', () => {
|
||||
node.parameters = {
|
||||
interleavedParams:
|
||||
"={{ 'Start ' + $fromAI('param1', 'First param', 'string') + ' Middle ' + $fromAI('param2', 'Second param', 'number') + ' End' }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.param1.description).toBe('First param');
|
||||
|
||||
expect(tool.schema.shape.param2).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.param2.description).toBe('Second param');
|
||||
});
|
||||
|
||||
it('should correctly parse $fromAI calls with complex JSON default values', () => {
|
||||
node.parameters = {
|
||||
complexJsonDefault:
|
||||
'={{ $fromAI(\'complexJson\', \'Param with complex JSON default\', \'json\', \'{"nested": {"key": "value"}, "array": [1, 2, 3]}\') }}',
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.complexJson._def.innerType).toBeInstanceOf(z.ZodRecord);
|
||||
expect(tool.schema.shape.complexJson.description).toBe('Param with complex JSON default');
|
||||
expect(tool.schema.shape.complexJson._def.defaultValue()).toEqual({
|
||||
nested: { key: 'value' },
|
||||
array: [1, 2, 3],
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore $fromAI calls embedded in non-string node parameters', () => {
|
||||
node.parameters = {
|
||||
numberParam: 42,
|
||||
booleanParam: false,
|
||||
objectParam: {
|
||||
innerString: "={{ $fromAI('innerParam', 'Inner param', 'string') }}",
|
||||
innerNumber: 100,
|
||||
innerObject: {
|
||||
deepParam: "={{ $fromAI('deepParam', 'Deep param', 'number') }}",
|
||||
},
|
||||
},
|
||||
arrayParam: [
|
||||
"={{ $fromAI('arrayParam1', 'First array param', 'string') }}",
|
||||
200,
|
||||
"={{ $fromAI('nestedArrayParam', 'Nested array param', 'boolean') }}",
|
||||
],
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.innerParam).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.innerParam.description).toBe('Inner param');
|
||||
|
||||
expect(tool.schema.shape.deepParam).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.deepParam.description).toBe('Deep param');
|
||||
|
||||
expect(tool.schema.shape.arrayParam1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.arrayParam1.description).toBe('First array param');
|
||||
|
||||
expect(tool.schema.shape.nestedArrayParam).toBeInstanceOf(z.ZodBoolean);
|
||||
expect(tool.schema.shape.nestedArrayParam.description).toBe('Nested array param');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escaping and Special Characters', () => {
|
||||
it('should handle escaped single quotes in parameter names and descriptions', () => {
|
||||
node.parameters = {
|
||||
escapedQuotesParam:
|
||||
"={{ $fromAI('paramName', 'Description with \\'escaped\\' quotes', 'string') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.paramName.description).toBe("Description with 'escaped' quotes");
|
||||
});
|
||||
|
||||
it('should handle escaped double quotes in parameter names and descriptions', () => {
|
||||
node.parameters = {
|
||||
escapedQuotesParam:
|
||||
'={{ $fromAI("paramName", "Description with \\"escaped\\" quotes", "string") }}',
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.paramName.description).toBe('Description with "escaped" quotes');
|
||||
});
|
||||
|
||||
it('should handle escaped backslashes in parameter names and descriptions', () => {
|
||||
node.parameters = {
|
||||
escapedBackslashesParam:
|
||||
"={{ $fromAI('paramName', 'Description with \\\\ backslashes', 'string') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.paramName.description).toBe('Description with \\ backslashes');
|
||||
});
|
||||
|
||||
it('should handle mixed escaped characters in parameter names and descriptions', () => {
|
||||
node.parameters = {
|
||||
mixedEscapesParam:
|
||||
'={{ $fromAI(`paramName`, \'Description with \\\'mixed" characters\', "number") }}',
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodNumber);
|
||||
expect(tool.schema.shape.paramName.description).toBe('Description with \'mixed" characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Limitations', () => {
|
||||
it('should ignore excess arguments in $fromAI calls beyond the fourth argument', () => {
|
||||
node.parameters = {
|
||||
excessArgsParam:
|
||||
"={{ $fromAI('excessArgs', 'Param with excess arguments', 'string', 'default', 'extraArg1', 'extraArg2') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.excessArgs._def.innerType).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.excessArgs.description).toBe('Param with excess arguments');
|
||||
expect(tool.schema.shape.excessArgs._def.defaultValue()).toBe('default');
|
||||
});
|
||||
|
||||
it('should correctly parse $fromAI calls with nested parentheses', () => {
|
||||
node.parameters = {
|
||||
nestedParenthesesParam:
|
||||
"={{ $fromAI('paramWithNested', 'Description with ((nested)) parentheses', 'string') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.paramWithNested).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.paramWithNested.description).toBe(
|
||||
'Description with ((nested)) parentheses',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle $fromAI calls with very long descriptions', () => {
|
||||
const longDescription = 'A'.repeat(1000);
|
||||
node.parameters = {
|
||||
longParam: `={{ $fromAI('longParam', '${longDescription}', 'string') }}`,
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.longParam).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.longParam.description).toBe(longDescription);
|
||||
});
|
||||
|
||||
it('should handle $fromAI calls with only some parameters', () => {
|
||||
node.parameters = {
|
||||
partialParam1: "={{ $fromAI('partial1') }}",
|
||||
partialParam2: "={{ $fromAI('partial2', 'Description only') }}",
|
||||
partialParam3: "={{ $fromAI('partial3', '', 'number') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.partial1).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.partial2).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.partial3).toBeInstanceOf(z.ZodNumber);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unicode and Internationalization', () => {
|
||||
it('should handle $fromAI calls with unicode characters', () => {
|
||||
node.parameters = {
|
||||
unicodeParam: "={{ $fromAI('unicodeParam', '🌈 Unicode parameter 你好', 'string') }}",
|
||||
};
|
||||
|
||||
const tool = createNodeAsTool(options).response;
|
||||
|
||||
expect(tool.schema.shape.unicodeParam).toBeInstanceOf(z.ZodString);
|
||||
expect(tool.schema.shape.unicodeParam.description).toBe('🌈 Unicode parameter 你好');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ExpressionError } from 'n8n-workflow';
|
||||
|
||||
import { ensureType } from '../ensure-type';
|
||||
|
||||
describe('ensureType', () => {
|
||||
it('throws error for null value', () => {
|
||||
expect(() => ensureType('string', null, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' must not be null"),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error for undefined value', () => {
|
||||
expect(() => ensureType('string', undefined, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' could not be 'undefined'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns string value without modification', () => {
|
||||
const value = 'hello';
|
||||
const expectedValue = value;
|
||||
const result = ensureType('string', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('returns number value without modification', () => {
|
||||
const value = 42;
|
||||
const expectedValue = value;
|
||||
const result = ensureType('number', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('returns boolean value without modification', () => {
|
||||
const value = true;
|
||||
const expectedValue = value;
|
||||
const result = ensureType('boolean', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('converts object to string if toType is string', () => {
|
||||
const value = { name: 'John' };
|
||||
const expectedValue = JSON.stringify(value);
|
||||
const result = ensureType('string', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('converts string to number if toType is number', () => {
|
||||
const value = '10';
|
||||
const expectedValue = 10;
|
||||
const result = ensureType('number', value, 'myParam');
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('throws error for invalid conversion to number', () => {
|
||||
const value = 'invalid';
|
||||
expect(() => ensureType('number', value, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses valid JSON string to object if toType is object', () => {
|
||||
const value = '{"name": "Alice"}';
|
||||
const expectedValue = JSON.parse(value);
|
||||
const result = ensureType('object', value, 'myParam');
|
||||
expect(result).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
it('throws error for invalid JSON string to object conversion', () => {
|
||||
const value = 'invalid_json';
|
||||
expect(() => ensureType('object', value, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' could not be parsed"),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error for non-array value if toType is array', () => {
|
||||
const value = { name: 'Alice' };
|
||||
expect(() => ensureType('array', value, 'myParam')).toThrowError(
|
||||
new ExpressionError("Parameter 'myParam' must be an array, but we got object"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { IRunExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { InvalidExecutionMetadataError } from '@/errors/invalid-execution-metadata.error';
|
||||
|
||||
import {
|
||||
setWorkflowExecutionMetadata,
|
||||
setAllWorkflowExecutionMetadata,
|
||||
KV_LIMIT,
|
||||
getWorkflowExecutionMetadata,
|
||||
getAllWorkflowExecutionMetadata,
|
||||
} from '../execution-metadata';
|
||||
|
||||
describe('Execution Metadata functions', () => {
|
||||
test('setWorkflowExecutionMetadata will set a value', () => {
|
||||
const metadata = {};
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
setWorkflowExecutionMetadata(executionData, 'test1', 'value1');
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test1: 'value1',
|
||||
});
|
||||
});
|
||||
|
||||
test('setAllWorkflowExecutionMetadata will set multiple values', () => {
|
||||
const metadata = {};
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
setAllWorkflowExecutionMetadata(executionData, {
|
||||
test1: 'value1',
|
||||
test2: 'value2',
|
||||
});
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test1: 'value1',
|
||||
test2: 'value2',
|
||||
});
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should only convert numbers to strings', () => {
|
||||
const metadata = {};
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
expect(() => setWorkflowExecutionMetadata(executionData, 'test1', 1234)).not.toThrow(
|
||||
InvalidExecutionMetadataError,
|
||||
);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test1: '1234',
|
||||
});
|
||||
|
||||
expect(() => setWorkflowExecutionMetadata(executionData, 'test2', {})).toThrow(
|
||||
InvalidExecutionMetadataError,
|
||||
);
|
||||
|
||||
expect(metadata).not.toEqual({
|
||||
test1: '1234',
|
||||
test2: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('setAllWorkflowExecutionMetadata should not convert values to strings and should set other values correctly', () => {
|
||||
const metadata = {};
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
expect(() =>
|
||||
setAllWorkflowExecutionMetadata(executionData, {
|
||||
test1: {} as unknown as string,
|
||||
test2: [] as unknown as string,
|
||||
test3: 'value3',
|
||||
test4: 'value4',
|
||||
}),
|
||||
).toThrow(InvalidExecutionMetadataError);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test3: 'value3',
|
||||
test4: 'value4',
|
||||
});
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should validate key characters', () => {
|
||||
const metadata = {};
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
expect(() => setWorkflowExecutionMetadata(executionData, 'te$t1$', 1234)).toThrow(
|
||||
InvalidExecutionMetadataError,
|
||||
);
|
||||
|
||||
expect(metadata).not.toEqual({
|
||||
test1: '1234',
|
||||
});
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should limit the number of metadata entries', () => {
|
||||
const metadata = {};
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
const expected: Record<string, string> = {};
|
||||
for (let i = 0; i < KV_LIMIT; i++) {
|
||||
expected[`test${i + 1}`] = `value${i + 1}`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < KV_LIMIT + 10; i++) {
|
||||
setWorkflowExecutionMetadata(executionData, `test${i + 1}`, `value${i + 1}`);
|
||||
}
|
||||
|
||||
expect(metadata).toEqual(expected);
|
||||
});
|
||||
|
||||
test('getWorkflowExecutionMetadata should return a single value for an existing key', () => {
|
||||
const metadata: Record<string, string> = { test1: 'value1' };
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
expect(getWorkflowExecutionMetadata(executionData, 'test1')).toBe('value1');
|
||||
});
|
||||
|
||||
test('getWorkflowExecutionMetadata should return undefined for an unset key', () => {
|
||||
const metadata: Record<string, string> = { test1: 'value1' };
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
expect(getWorkflowExecutionMetadata(executionData, 'test2')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getAllWorkflowExecutionMetadata should return all metadata', () => {
|
||||
const metadata: Record<string, string> = { test1: 'value1', test2: 'value2' };
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
expect(getAllWorkflowExecutionMetadata(executionData)).toEqual(metadata);
|
||||
});
|
||||
|
||||
test('getAllWorkflowExecutionMetadata should not an object that modifies internal state', () => {
|
||||
const metadata: Record<string, string> = { test1: 'value1', test2: 'value2' };
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
getAllWorkflowExecutionMetadata(executionData).test1 = 'changed';
|
||||
|
||||
expect(metadata.test1).not.toBe('changed');
|
||||
expect(metadata.test1).toBe('value1');
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should truncate long keys', () => {
|
||||
const metadata = {};
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
setWorkflowExecutionMetadata(
|
||||
executionData,
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
|
||||
'value1',
|
||||
);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: 'value1',
|
||||
});
|
||||
});
|
||||
|
||||
test('setWorkflowExecutionMetadata should truncate long values', () => {
|
||||
const metadata = {};
|
||||
const executionData = {
|
||||
resultData: {
|
||||
metadata,
|
||||
},
|
||||
} as IRunExecutionData;
|
||||
|
||||
setWorkflowExecutionMetadata(
|
||||
executionData,
|
||||
'test1',
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
|
||||
);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
test1:
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IRunExecutionData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
SecretsHelpersBase,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants';
|
||||
|
||||
import { getAdditionalKeys } from '../get-additional-keys';
|
||||
|
||||
describe('getAdditionalKeys', () => {
|
||||
const secretsHelpers = mock<SecretsHelpersBase>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: '123',
|
||||
webhookWaitingBaseUrl: 'https://webhook.test',
|
||||
formWaitingBaseUrl: 'https://form.test',
|
||||
variables: { testVar: 'value' },
|
||||
secretsHelpers,
|
||||
});
|
||||
|
||||
const runExecutionData = mock<IRunExecutionData>({
|
||||
resultData: {
|
||||
runData: {},
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
LoggerProxy.init(mock());
|
||||
secretsHelpers.hasProvider.mockReturnValue(true);
|
||||
secretsHelpers.hasSecret.mockReturnValue(true);
|
||||
secretsHelpers.getSecret.mockReturnValue('secret-value');
|
||||
secretsHelpers.listSecrets.mockReturnValue(['secret1']);
|
||||
secretsHelpers.listProviders.mockReturnValue(['provider1']);
|
||||
});
|
||||
|
||||
it('should use placeholder execution ID when none provided', () => {
|
||||
const noIdData = { ...additionalData, executionId: undefined };
|
||||
const result = getAdditionalKeys(noIdData, 'manual', null);
|
||||
|
||||
expect(result.$execution?.id).toBe(PLACEHOLDER_EMPTY_EXECUTION_ID);
|
||||
});
|
||||
|
||||
it('should return production mode when not manual', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'internal', null);
|
||||
|
||||
expect(result.$execution?.mode).toBe('production');
|
||||
});
|
||||
|
||||
it('should include customData methods when runExecutionData is provided', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||
|
||||
expect(result.$execution?.customData).toBeDefined();
|
||||
expect(typeof result.$execution?.customData?.set).toBe('function');
|
||||
expect(typeof result.$execution?.customData?.setAll).toBe('function');
|
||||
expect(typeof result.$execution?.customData?.get).toBe('function');
|
||||
expect(typeof result.$execution?.customData?.getAll).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle customData operations correctly', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||
const customData = result.$execution?.customData;
|
||||
|
||||
customData?.set('testKey', 'testValue');
|
||||
expect(customData?.get('testKey')).toBe('testValue');
|
||||
|
||||
customData?.setAll({ key1: 'value1', key2: 'value2' });
|
||||
const allData = customData?.getAll();
|
||||
expect(allData).toEqual({
|
||||
testKey: 'testValue',
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include secrets when enabled', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: true });
|
||||
|
||||
expect(result.$secrets).toBeDefined();
|
||||
expect((result.$secrets?.provider1 as IDataObject).secret1).toEqual('secret-value');
|
||||
});
|
||||
|
||||
it('should not include secrets when disabled', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: false });
|
||||
|
||||
expect(result.$secrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw errors in manual mode', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||
|
||||
expect(() => {
|
||||
result.$execution?.customData?.set('invalid*key', 'value');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should correctly set resume URLs', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||
|
||||
expect(result.$execution?.resumeUrl).toBe('https://webhook.test/123');
|
||||
expect(result.$execution?.resumeFormUrl).toBe('https://form.test/123');
|
||||
expect(result.$resumeWebhookUrl).toBe('https://webhook.test/123'); // Test deprecated property
|
||||
});
|
||||
|
||||
it('should return test mode when manual', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||
|
||||
expect(result.$execution?.mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return variables from additionalData', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||
expect(result.$vars?.testVar).toEqual('value');
|
||||
});
|
||||
|
||||
it('should handle errors in non-manual mode without throwing', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'internal', runExecutionData);
|
||||
const customData = result.$execution?.customData;
|
||||
|
||||
expect(() => {
|
||||
customData?.set('invalid*key', 'value');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return undefined customData when runExecutionData is null', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||
|
||||
expect(result.$execution?.customData).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect metadata KV limit', () => {
|
||||
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||
const customData = result.$execution?.customData;
|
||||
|
||||
// Add 11 key-value pairs (exceeding the limit of 10)
|
||||
for (let i = 0; i < 11; i++) {
|
||||
customData?.set(`key${i}`, `value${i}`);
|
||||
}
|
||||
|
||||
const allData = customData?.getAll() ?? {};
|
||||
expect(Object.keys(allData)).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INode,
|
||||
ITaskDataConnections,
|
||||
IRunExecutionData,
|
||||
INodeExecutionData,
|
||||
IExecuteData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { ExecuteContext } from '../../execute-context';
|
||||
|
||||
describe('getInputConnectionData', () => {
|
||||
const agentNode = mock<INode>({
|
||||
name: 'Test Agent',
|
||||
type: 'test.agent',
|
||||
parameters: {},
|
||||
});
|
||||
const agentNodeType = mock<INodeType>({
|
||||
description: {
|
||||
inputs: [],
|
||||
},
|
||||
});
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const workflow = mock<Workflow>({
|
||||
id: 'test-workflow',
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
const runExecutionData = mock<IRunExecutionData>({
|
||||
resultData: { runData: {} },
|
||||
});
|
||||
const connectionInputData = [] as INodeExecutionData[];
|
||||
const inputData = {} as ITaskDataConnections;
|
||||
const executeData = {} as IExecuteData;
|
||||
|
||||
const hooks = mock<Required<IWorkflowExecuteAdditionalData['hooks']>>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ hooks });
|
||||
|
||||
let executeContext: ExecuteContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
executeContext = new ExecuteContext(
|
||||
workflow,
|
||||
agentNode,
|
||||
additionalData,
|
||||
'internal',
|
||||
runExecutionData,
|
||||
0,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
executeData,
|
||||
[],
|
||||
);
|
||||
|
||||
jest.spyOn(executeContext, 'getNode').mockReturnValue(agentNode);
|
||||
nodeTypes.getByNameAndVersion
|
||||
.calledWith(agentNode.type, expect.anything())
|
||||
.mockReturnValue(agentNodeType);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
NodeConnectionType.AiAgent,
|
||||
NodeConnectionType.AiChain,
|
||||
NodeConnectionType.AiDocument,
|
||||
NodeConnectionType.AiEmbedding,
|
||||
NodeConnectionType.AiLanguageModel,
|
||||
NodeConnectionType.AiMemory,
|
||||
NodeConnectionType.AiOutputParser,
|
||||
NodeConnectionType.AiRetriever,
|
||||
NodeConnectionType.AiTextSplitter,
|
||||
NodeConnectionType.AiVectorStore,
|
||||
] as const)('%s', (connectionType) => {
|
||||
const response = mock();
|
||||
const node = mock<INode>({
|
||||
name: 'First Node',
|
||||
type: 'test.type',
|
||||
disabled: false,
|
||||
});
|
||||
const secondNode = mock<INode>({ name: 'Second Node', disabled: false });
|
||||
const supplyData = jest.fn().mockResolvedValue({ response });
|
||||
const nodeType = mock<INodeType>({ supplyData });
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion
|
||||
.calledWith(node.type, expect.anything())
|
||||
.mockReturnValue(nodeType);
|
||||
workflow.getParentNodes
|
||||
.calledWith(agentNode.name, connectionType)
|
||||
.mockReturnValue([node.name]);
|
||||
workflow.getNode.calledWith(node.name).mockReturnValue(node);
|
||||
workflow.getNode.calledWith(secondNode.name).mockReturnValue(secondNode);
|
||||
});
|
||||
|
||||
it('should throw when no inputs are defined', async () => {
|
||||
agentNodeType.description.inputs = [];
|
||||
|
||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||
'Node does not have input of type',
|
||||
);
|
||||
expect(supplyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return undefined when no nodes are connected and input is not required', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: connectionType,
|
||||
maxConnections: 1,
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||
|
||||
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
||||
expect(result).toBeUndefined();
|
||||
expect(supplyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when too many nodes are connected', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: connectionType,
|
||||
maxConnections: 1,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
workflow.getParentNodes.mockReturnValueOnce([node.name, secondNode.name]);
|
||||
|
||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||
`Only 1 ${connectionType} sub-nodes are/is allowed to be connected`,
|
||||
);
|
||||
expect(supplyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when required node is not connected', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: connectionType,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||
|
||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||
'must be connected and enabled',
|
||||
);
|
||||
expect(supplyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle disabled nodes', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: connectionType,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const disabledNode = mock<INode>({
|
||||
name: 'Disabled Node',
|
||||
type: 'test.type',
|
||||
disabled: true,
|
||||
});
|
||||
workflow.getParentNodes.mockReturnValueOnce([disabledNode.name]);
|
||||
workflow.getNode.calledWith(disabledNode.name).mockReturnValue(disabledNode);
|
||||
|
||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||
'must be connected and enabled',
|
||||
);
|
||||
expect(supplyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle node execution errors', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: connectionType,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
supplyData.mockRejectedValueOnce(new Error('supplyData error'));
|
||||
|
||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||
`Error in sub-node ${node.name}`,
|
||||
);
|
||||
expect(supplyData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should propagate configuration errors', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: connectionType,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const configError = new NodeOperationError(node, 'Config Error in node', {
|
||||
functionality: 'configuration-node',
|
||||
});
|
||||
supplyData.mockRejectedValueOnce(configError);
|
||||
|
||||
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||
configError.message,
|
||||
);
|
||||
expect(nodeType.supplyData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle close functions', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: connectionType,
|
||||
maxConnections: 1,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const closeFunction = jest.fn();
|
||||
supplyData.mockResolvedValueOnce({ response, closeFunction });
|
||||
|
||||
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
||||
expect(result).toBe(response);
|
||||
expect(supplyData).toHaveBeenCalled();
|
||||
// @ts-expect-error private property
|
||||
expect(executeContext.closeFunctions).toContain(closeFunction);
|
||||
});
|
||||
});
|
||||
|
||||
describe(NodeConnectionType.AiTool, () => {
|
||||
const mockTool = mock<Tool>();
|
||||
const toolNode = mock<INode>({
|
||||
name: 'Test Tool',
|
||||
type: 'test.tool',
|
||||
disabled: false,
|
||||
});
|
||||
const supplyData = jest.fn().mockResolvedValue({ response: mockTool });
|
||||
const toolNodeType = mock<INodeType>({ supplyData });
|
||||
|
||||
const secondToolNode = mock<INode>({ name: 'test.secondTool', disabled: false });
|
||||
const secondMockTool = mock<Tool>();
|
||||
const secondToolNodeType = mock<INodeType>({
|
||||
supplyData: jest.fn().mockResolvedValue({ response: secondMockTool }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion
|
||||
.calledWith(toolNode.type, expect.anything())
|
||||
.mockReturnValue(toolNodeType);
|
||||
workflow.getParentNodes
|
||||
.calledWith(agentNode.name, NodeConnectionType.AiTool)
|
||||
.mockReturnValue([toolNode.name]);
|
||||
workflow.getNode.calledWith(toolNode.name).mockReturnValue(toolNode);
|
||||
workflow.getNode.calledWith(secondToolNode.name).mockReturnValue(secondToolNode);
|
||||
});
|
||||
|
||||
it('should return empty array when no tools are connected and input is not required', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: NodeConnectionType.AiTool,
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||
|
||||
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
|
||||
expect(result).toEqual([]);
|
||||
expect(supplyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when required tool node is not connected', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: NodeConnectionType.AiTool,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||
|
||||
await expect(
|
||||
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
|
||||
).rejects.toThrow('must be connected and enabled');
|
||||
expect(supplyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle disabled tool nodes', async () => {
|
||||
const disabledToolNode = mock<INode>({
|
||||
name: 'Disabled Tool',
|
||||
type: 'test.tool',
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: NodeConnectionType.AiTool,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
workflow.getParentNodes
|
||||
.calledWith(agentNode.name, NodeConnectionType.AiTool)
|
||||
.mockReturnValue([disabledToolNode.name]);
|
||||
workflow.getNode.calledWith(disabledToolNode.name).mockReturnValue(disabledToolNode);
|
||||
|
||||
await expect(
|
||||
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
|
||||
).rejects.toThrow('must be connected and enabled');
|
||||
expect(supplyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple connected tools', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: NodeConnectionType.AiTool,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
nodeTypes.getByNameAndVersion
|
||||
.calledWith(secondToolNode.type, expect.anything())
|
||||
.mockReturnValue(secondToolNodeType);
|
||||
|
||||
workflow.getParentNodes
|
||||
.calledWith(agentNode.name, NodeConnectionType.AiTool)
|
||||
.mockReturnValue([toolNode.name, secondToolNode.name]);
|
||||
|
||||
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
|
||||
expect(result).toEqual([mockTool, secondMockTool]);
|
||||
expect(supplyData).toHaveBeenCalled();
|
||||
expect(secondToolNodeType.supplyData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle tool execution errors', async () => {
|
||||
supplyData.mockRejectedValueOnce(new Error('Tool execution error'));
|
||||
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: NodeConnectionType.AiTool,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
|
||||
).rejects.toThrow(`Error in sub-node ${toolNode.name}`);
|
||||
expect(supplyData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the tool when there are no issues', async () => {
|
||||
agentNodeType.description.inputs = [
|
||||
{
|
||||
type: NodeConnectionType.AiTool,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
|
||||
expect(result).toEqual([mockTool]);
|
||||
expect(supplyData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { IDataObject, INode, INodeType } from 'n8n-workflow';
|
||||
|
||||
import { validateValueAgainstSchema } from '../validate-value-against-schema';
|
||||
|
||||
describe('validateValueAgainstSchema', () => {
|
||||
test('should validate fixedCollection values parameter', () => {
|
||||
const nodeType = {
|
||||
description: {
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Fields to Set',
|
||||
name: 'fields',
|
||||
placeholder: 'Add Field',
|
||||
type: 'fixedCollection',
|
||||
description: 'Edit existing fields or add new ones to modify the output data',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
sortable: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. fieldName',
|
||||
description:
|
||||
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
|
||||
requiresDataPath: 'single',
|
||||
},
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
description: 'The field value type',
|
||||
options: [
|
||||
{
|
||||
name: 'String',
|
||||
value: 'stringValue',
|
||||
},
|
||||
{
|
||||
name: 'Number',
|
||||
value: 'numberValue',
|
||||
},
|
||||
{
|
||||
name: 'Boolean',
|
||||
value: 'booleanValue',
|
||||
},
|
||||
{
|
||||
name: 'Array',
|
||||
value: 'arrayValue',
|
||||
},
|
||||
{
|
||||
name: 'Object',
|
||||
value: 'objectValue',
|
||||
},
|
||||
],
|
||||
default: 'stringValue',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'stringValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['stringValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'string',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'numberValue',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['numberValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'booleanValue',
|
||||
type: 'options',
|
||||
default: 'true',
|
||||
options: [
|
||||
{
|
||||
name: 'True',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
name: 'False',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['booleanValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'boolean',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'arrayValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['arrayValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'array',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'objectValue',
|
||||
type: 'json',
|
||||
default: '={}',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['objectValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'object',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['manual'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as INodeType;
|
||||
|
||||
const node = {
|
||||
parameters: {
|
||||
mode: 'manual',
|
||||
duplicateItem: false,
|
||||
fields: {
|
||||
values: [
|
||||
{
|
||||
name: 'num1',
|
||||
type: 'numberValue',
|
||||
numberValue: '=str',
|
||||
},
|
||||
],
|
||||
},
|
||||
include: 'none',
|
||||
options: {},
|
||||
},
|
||||
name: 'Edit Fields2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3,
|
||||
} as unknown as INode;
|
||||
|
||||
const values = [
|
||||
{
|
||||
name: 'num1',
|
||||
type: 'numberValue',
|
||||
numberValue: '55',
|
||||
},
|
||||
{
|
||||
name: 'str1',
|
||||
type: 'stringValue',
|
||||
stringValue: 42, //validateFieldType does not change the type of string value
|
||||
},
|
||||
{
|
||||
name: 'arr1',
|
||||
type: 'arrayValue',
|
||||
arrayValue: "['foo', 'bar']",
|
||||
},
|
||||
{
|
||||
name: 'obj',
|
||||
type: 'objectValue',
|
||||
objectValue: '{ "key": "value" }',
|
||||
},
|
||||
];
|
||||
|
||||
const parameterName = 'fields.values';
|
||||
|
||||
const result = validateValueAgainstSchema(node, nodeType, values, parameterName, 0, 0);
|
||||
|
||||
// value should be type number
|
||||
expect(typeof (result as IDataObject[])[0].numberValue).toEqual('number');
|
||||
// string value should remain unchanged
|
||||
expect(typeof (result as IDataObject[])[1].stringValue).toEqual('number');
|
||||
// value should be type array
|
||||
expect(typeof (result as IDataObject[])[2].arrayValue).toEqual('object');
|
||||
expect(Array.isArray((result as IDataObject[])[2].arrayValue)).toEqual(true);
|
||||
// value should be type object
|
||||
expect(typeof (result as IDataObject[])[3].objectValue).toEqual('object');
|
||||
expect(((result as IDataObject[])[3].objectValue as IDataObject).key).toEqual('value');
|
||||
});
|
||||
|
||||
test('should validate single value parameter', () => {
|
||||
const nodeType = {
|
||||
description: {
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'numberValue',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
validateType: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as INodeType;
|
||||
|
||||
const node = {
|
||||
parameters: {
|
||||
mode: 'manual',
|
||||
duplicateItem: false,
|
||||
numberValue: '777',
|
||||
include: 'none',
|
||||
options: {},
|
||||
},
|
||||
name: 'Edit Fields2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3,
|
||||
} as unknown as INode;
|
||||
|
||||
const value = '777';
|
||||
|
||||
const parameterName = 'numberValue';
|
||||
|
||||
const result = validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0);
|
||||
|
||||
// value should be type number
|
||||
expect(typeof result).toEqual('number');
|
||||
});
|
||||
|
||||
describe('when the mode is in Fixed mode, and the node is a resource mapper', () => {
|
||||
const nodeType = {
|
||||
description: {
|
||||
properties: [
|
||||
{
|
||||
name: 'operation',
|
||||
type: 'resourceMapper',
|
||||
typeOptions: {
|
||||
resourceMapper: {
|
||||
mode: 'add',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as INodeType;
|
||||
|
||||
const node = {
|
||||
parameters: {
|
||||
operation: {
|
||||
schema: [
|
||||
{ id: 'num', type: 'number', required: true },
|
||||
{ id: 'str', type: 'string', required: true },
|
||||
{ id: 'obj', type: 'object', required: true },
|
||||
{ id: 'arr', type: 'array', required: true },
|
||||
],
|
||||
attemptToConvertTypes: true,
|
||||
mappingMode: '',
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
} as unknown as INode;
|
||||
|
||||
const parameterName = 'operation.value';
|
||||
|
||||
describe('should correctly validate values for', () => {
|
||||
test.each([
|
||||
{ num: 0 },
|
||||
{ num: 23 },
|
||||
{ num: -0 },
|
||||
{ num: -Infinity },
|
||||
{ num: Infinity },
|
||||
{ str: '' },
|
||||
{ str: ' ' },
|
||||
{ str: 'hello' },
|
||||
{ arr: [] },
|
||||
{ obj: {} },
|
||||
])('%s', (value) => {
|
||||
expect(() =>
|
||||
validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should throw an error for', () => {
|
||||
test.each([{ num: NaN }, { num: undefined }, { num: null }])('%s', (value) => {
|
||||
expect(() =>
|
||||
validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { INodeParameters, NodeParameterValueType } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Clean up parameter data to make sure that only valid data gets returned
|
||||
* INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking
|
||||
*/
|
||||
export function cleanupParameterData(inputData: NodeParameterValueType): void {
|
||||
if (typeof inputData !== 'object' || inputData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(inputData)) {
|
||||
inputData.forEach((value) => cleanupParameterData(value as NodeParameterValueType));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof inputData === 'object') {
|
||||
Object.keys(inputData).forEach((key) => {
|
||||
const value = (inputData as INodeParameters)[key];
|
||||
if (typeof value === 'object') {
|
||||
if (DateTime.isDateTime(value)) {
|
||||
// Is a special luxon date so convert to string
|
||||
(inputData as INodeParameters)[key] = value.toString();
|
||||
} else {
|
||||
cleanupParameterData(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import type { IDataObject, INode, INodeType } from 'n8n-workflow';
|
||||
import { jsonParse, NodeOperationError } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
type AllowedTypes = 'string' | 'number' | 'boolean' | 'json';
|
||||
interface FromAIArgument {
|
||||
key: string;
|
||||
description?: string;
|
||||
type?: AllowedTypes;
|
||||
defaultValue?: string | number | boolean | Record<string, unknown>;
|
||||
}
|
||||
|
||||
type ParserOptions = {
|
||||
node: INode;
|
||||
nodeType: INodeType;
|
||||
handleToolInvocation: (toolArgs: IDataObject) => Promise<unknown>;
|
||||
};
|
||||
|
||||
// This file is temporarily duplicated in `packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts`
|
||||
// Please apply any changes in both files
|
||||
|
||||
/**
|
||||
* AIParametersParser
|
||||
*
|
||||
* This class encapsulates the logic for parsing node parameters, extracting $fromAI calls,
|
||||
* generating Zod schemas, and creating LangChain tools.
|
||||
*/
|
||||
class AIParametersParser {
|
||||
/**
|
||||
* Constructs an instance of AIParametersParser.
|
||||
*/
|
||||
constructor(private readonly options: ParserOptions) {}
|
||||
|
||||
/**
|
||||
* Generates a Zod schema based on the provided FromAIArgument placeholder.
|
||||
* @param placeholder The FromAIArgument object containing key, type, description, and defaultValue.
|
||||
* @returns A Zod schema corresponding to the placeholder's type and constraints.
|
||||
*/
|
||||
private generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny {
|
||||
let schema: z.ZodTypeAny;
|
||||
|
||||
switch (placeholder.type?.toLowerCase()) {
|
||||
case 'string':
|
||||
schema = z.string();
|
||||
break;
|
||||
case 'number':
|
||||
schema = z.number();
|
||||
break;
|
||||
case 'boolean':
|
||||
schema = z.boolean();
|
||||
break;
|
||||
case 'json':
|
||||
schema = z.record(z.any());
|
||||
break;
|
||||
default:
|
||||
schema = z.string();
|
||||
}
|
||||
|
||||
if (placeholder.description) {
|
||||
schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim());
|
||||
}
|
||||
|
||||
if (placeholder.defaultValue !== undefined) {
|
||||
schema = schema.default(placeholder.defaultValue);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively traverses the nodeParameters object to find all $fromAI calls.
|
||||
* @param payload The current object or value being traversed.
|
||||
* @param collectedArgs The array collecting FromAIArgument objects.
|
||||
*/
|
||||
private traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) {
|
||||
if (typeof payload === 'string') {
|
||||
const fromAICalls = this.extractFromAICalls(payload);
|
||||
fromAICalls.forEach((call) => collectedArgs.push(call));
|
||||
} else if (Array.isArray(payload)) {
|
||||
payload.forEach((item: unknown) => this.traverseNodeParameters(item, collectedArgs));
|
||||
} else if (typeof payload === 'object' && payload !== null) {
|
||||
Object.values(payload).forEach((value) => this.traverseNodeParameters(value, collectedArgs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all $fromAI calls from a given string
|
||||
* @param str The string to search for $fromAI calls.
|
||||
* @returns An array of FromAIArgument objects.
|
||||
*
|
||||
* This method uses a regular expression to find the start of each $fromAI function call
|
||||
* in the input string. It then employs a character-by-character parsing approach to
|
||||
* accurately extract the arguments of each call, handling nested parentheses and quoted strings.
|
||||
*
|
||||
* The parsing process:
|
||||
* 1. Finds the starting position of a $fromAI call using regex.
|
||||
* 2. Iterates through characters, keeping track of parentheses depth and quote status.
|
||||
* 3. Handles escaped characters within quotes to avoid premature quote closing.
|
||||
* 4. Builds the argument string until the matching closing parenthesis is found.
|
||||
* 5. Parses the extracted argument string into a FromAIArgument object.
|
||||
* 6. Repeats the process for all $fromAI calls in the input string.
|
||||
*
|
||||
*/
|
||||
private extractFromAICalls(str: string): FromAIArgument[] {
|
||||
const args: FromAIArgument[] = [];
|
||||
// Regular expression to match the start of a $fromAI function call
|
||||
const pattern = /\$fromAI\s*\(\s*/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(str)) !== null) {
|
||||
const startIndex = match.index + match[0].length;
|
||||
let current = startIndex;
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let parenthesesCount = 1;
|
||||
let argsString = '';
|
||||
|
||||
// Parse the arguments string, handling nested parentheses and quotes
|
||||
while (current < str.length && parenthesesCount > 0) {
|
||||
const char = str[current];
|
||||
|
||||
if (inQuotes) {
|
||||
// Handle characters inside quotes, including escaped characters
|
||||
if (char === '\\' && current + 1 < str.length) {
|
||||
argsString += char + str[current + 1];
|
||||
current += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === quoteChar) {
|
||||
inQuotes = false;
|
||||
quoteChar = '';
|
||||
}
|
||||
argsString += char;
|
||||
} else {
|
||||
// Handle characters outside quotes
|
||||
if (['"', "'", '`'].includes(char)) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
} else if (char === '(') {
|
||||
parenthesesCount++;
|
||||
} else if (char === ')') {
|
||||
parenthesesCount--;
|
||||
}
|
||||
|
||||
// Only add characters if we're still inside the main parentheses
|
||||
if (parenthesesCount > 0 || char !== ')') {
|
||||
argsString += char;
|
||||
}
|
||||
}
|
||||
|
||||
current++;
|
||||
}
|
||||
|
||||
// If parentheses are balanced, parse the arguments
|
||||
if (parenthesesCount === 0) {
|
||||
try {
|
||||
const parsedArgs = this.parseArguments(argsString);
|
||||
args.push(parsedArgs);
|
||||
} catch (error) {
|
||||
// If parsing fails, throw an ApplicationError with details
|
||||
throw new NodeOperationError(
|
||||
this.options.node,
|
||||
`Failed to parse $fromAI arguments: ${argsString}: ${error}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Log an error if parentheses are unbalanced
|
||||
throw new NodeOperationError(
|
||||
this.options.node,
|
||||
`Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the arguments of a single $fromAI function call.
|
||||
* @param argsString The string containing the function arguments.
|
||||
* @returns A FromAIArgument object.
|
||||
*/
|
||||
private parseArguments(argsString: string): FromAIArgument {
|
||||
// Split arguments by commas not inside quotes
|
||||
const args: string[] = [];
|
||||
let currentArg = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let escapeNext = false;
|
||||
|
||||
for (let i = 0; i < argsString.length; i++) {
|
||||
const char = argsString[i];
|
||||
|
||||
if (escapeNext) {
|
||||
currentArg += char;
|
||||
escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['"', "'", '`'].includes(char)) {
|
||||
if (!inQuotes) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
currentArg += char;
|
||||
} else if (char === quoteChar) {
|
||||
inQuotes = false;
|
||||
quoteChar = '';
|
||||
currentArg += char;
|
||||
} else {
|
||||
currentArg += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ',' && !inQuotes) {
|
||||
args.push(currentArg.trim());
|
||||
currentArg = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
currentArg += char;
|
||||
}
|
||||
|
||||
if (currentArg) {
|
||||
args.push(currentArg.trim());
|
||||
}
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
const cleanArgs = args.map((arg) => {
|
||||
const trimmed = arg.trim();
|
||||
if (
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('`') && trimmed.endsWith('`')) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||
) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\`/g, '`')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
|
||||
const type = cleanArgs?.[2] || 'string';
|
||||
|
||||
if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) {
|
||||
throw new NodeOperationError(this.options.node, `Invalid type: ${type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
key: cleanArgs[0] || '',
|
||||
description: cleanArgs[1],
|
||||
type: (cleanArgs?.[2] ?? 'string') as AllowedTypes,
|
||||
defaultValue: this.parseDefaultValue(cleanArgs[3]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the default value, preserving its original type.
|
||||
* @param value The default value as a string.
|
||||
* @returns The parsed default value in its appropriate type.
|
||||
*/
|
||||
private parseDefaultValue(
|
||||
value: string | undefined,
|
||||
): string | number | boolean | Record<string, unknown> | undefined {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
const lowerValue = value.toLowerCase();
|
||||
if (lowerValue === 'true') return true;
|
||||
if (lowerValue === 'false') return false;
|
||||
if (!isNaN(Number(value))) return Number(value);
|
||||
try {
|
||||
return jsonParse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and validates the Zod schema for the tool.
|
||||
*
|
||||
* This method:
|
||||
* 1. Collects all $fromAI arguments from node parameters
|
||||
* 2. Validates parameter keys against naming rules
|
||||
* 3. Checks for duplicate keys and ensures consistency
|
||||
* 4. Generates a Zod schema from the validated arguments
|
||||
*
|
||||
* @throws {NodeOperationError} When parameter keys are invalid or when duplicate keys have inconsistent definitions
|
||||
* @returns {z.ZodObject} A Zod schema object representing the structure and validation rules for the node parameters
|
||||
*/
|
||||
private getSchema() {
|
||||
const { node } = this.options;
|
||||
const collectedArguments: FromAIArgument[] = [];
|
||||
this.traverseNodeParameters(node.parameters, collectedArguments);
|
||||
|
||||
// Validate each collected argument
|
||||
const nameValidationRegex = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
const keyMap = new Map<string, FromAIArgument>();
|
||||
for (const argument of collectedArguments) {
|
||||
if (argument.key.length === 0 || !nameValidationRegex.test(argument.key)) {
|
||||
const isEmptyError = 'You must specify a key when using $fromAI()';
|
||||
const isInvalidError = `Parameter key \`${argument.key}\` is invalid`;
|
||||
const error = new Error(argument.key.length === 0 ? isEmptyError : isInvalidError);
|
||||
throw new NodeOperationError(node, error, {
|
||||
description:
|
||||
'Invalid parameter key, must be between 1 and 64 characters long and only contain letters, numbers, underscores, and hyphens',
|
||||
});
|
||||
}
|
||||
|
||||
if (keyMap.has(argument.key)) {
|
||||
// If the key already exists in the Map
|
||||
const existingArg = keyMap.get(argument.key)!;
|
||||
|
||||
// Check if the existing argument has the same description and type
|
||||
if (
|
||||
existingArg.description !== argument.description ||
|
||||
existingArg.type !== argument.type
|
||||
) {
|
||||
// If not, throw an error for inconsistent duplicate keys
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Duplicate key '${argument.key}' found with different description or type`,
|
||||
{
|
||||
description:
|
||||
'Ensure all $fromAI() calls with the same key have consistent descriptions and types',
|
||||
},
|
||||
);
|
||||
}
|
||||
// If the duplicate key has consistent description and type, it's allowed (no action needed)
|
||||
} else {
|
||||
// If the key doesn't exist in the Map, add it
|
||||
keyMap.set(argument.key, argument);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicate keys, latest occurrence takes precedence
|
||||
const uniqueArgsMap = collectedArguments.reduce((map, arg) => {
|
||||
map.set(arg.key, arg);
|
||||
return map;
|
||||
}, new Map<string, FromAIArgument>());
|
||||
|
||||
const uniqueArguments = Array.from(uniqueArgsMap.values());
|
||||
|
||||
// Generate Zod schema from unique arguments
|
||||
const schemaObj = uniqueArguments.reduce((acc: Record<string, z.ZodTypeAny>, placeholder) => {
|
||||
acc[placeholder.key] = this.generateZodSchema(placeholder);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return z.object(schemaObj).required();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a description for a node based on the provided parameters.
|
||||
* @param node The node type.
|
||||
* @param nodeParameters The parameters of the node.
|
||||
* @returns A string description for the node.
|
||||
*/
|
||||
private getDescription(): string {
|
||||
const { node, nodeType } = this.options;
|
||||
const manualDescription = node.parameters.toolDescription as string;
|
||||
|
||||
if (node.parameters.descriptionType === 'auto') {
|
||||
const resource = node.parameters.resource as string;
|
||||
const operation = node.parameters.operation as string;
|
||||
let description = nodeType.description.description;
|
||||
if (resource) {
|
||||
description += `\n Resource: ${resource}`;
|
||||
}
|
||||
if (operation) {
|
||||
description += `\n Operation: ${operation}`;
|
||||
}
|
||||
return description.trim();
|
||||
}
|
||||
if (node.parameters.descriptionType === 'manual') {
|
||||
return manualDescription ?? nodeType.description.description;
|
||||
}
|
||||
|
||||
return nodeType.description.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DynamicStructuredTool from a node.
|
||||
* @returns A DynamicStructuredTool instance.
|
||||
*/
|
||||
createTool(): DynamicStructuredTool {
|
||||
const { node, nodeType } = this.options;
|
||||
const schema = this.getSchema();
|
||||
const description = this.getDescription();
|
||||
const nodeName = node.name.replace(/ /g, '_');
|
||||
const name = nodeName || nodeType.description.name;
|
||||
|
||||
return new DynamicStructuredTool({
|
||||
name,
|
||||
description,
|
||||
schema,
|
||||
func: async (toolArgs: z.infer<typeof schema>) =>
|
||||
await this.options.handleToolInvocation(toolArgs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts node into LangChain tool by analyzing node parameters,
|
||||
* identifying placeholders using the $fromAI function, and generating a Zod schema. It then creates
|
||||
* a DynamicStructuredTool that can be used in LangChain workflows.
|
||||
*/
|
||||
export function createNodeAsTool(options: ParserOptions) {
|
||||
const parser = new AIParametersParser(options);
|
||||
return { response: parser.createTool() };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { EnsureTypeOptions } from 'n8n-workflow';
|
||||
import { ExpressionError } from 'n8n-workflow';
|
||||
|
||||
export function ensureType(
|
||||
toType: EnsureTypeOptions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parameterValue: any,
|
||||
parameterName: string,
|
||||
errorOptions?: { itemIndex?: number; runIndex?: number; nodeCause?: string },
|
||||
): string | number | boolean | object {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
let returnData = parameterValue;
|
||||
|
||||
if (returnData === null) {
|
||||
throw new ExpressionError(`Parameter '${parameterName}' must not be null`, errorOptions);
|
||||
}
|
||||
|
||||
if (returnData === undefined) {
|
||||
throw new ExpressionError(
|
||||
`Parameter '${parameterName}' could not be 'undefined'`,
|
||||
errorOptions,
|
||||
);
|
||||
}
|
||||
|
||||
if (['object', 'array', 'json'].includes(toType)) {
|
||||
if (typeof returnData !== 'object') {
|
||||
// if value is not an object and is string try to parse it, else throw an error
|
||||
if (typeof returnData === 'string' && returnData.length) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const parsedValue = JSON.parse(returnData);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
returnData = parsedValue;
|
||||
} catch (error) {
|
||||
throw new ExpressionError(`Parameter '${parameterName}' could not be parsed`, {
|
||||
...errorOptions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new ExpressionError(
|
||||
`Parameter '${parameterName}' must be an ${toType}, but we got '${String(parameterValue)}'`,
|
||||
errorOptions,
|
||||
);
|
||||
}
|
||||
} else if (toType === 'json') {
|
||||
// value is an object, make sure it is valid JSON
|
||||
try {
|
||||
JSON.stringify(returnData);
|
||||
} catch (error) {
|
||||
throw new ExpressionError(`Parameter '${parameterName}' is not valid JSON`, {
|
||||
...errorOptions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toType === 'array' && !Array.isArray(returnData)) {
|
||||
// value is not an array, but has to be
|
||||
throw new ExpressionError(
|
||||
`Parameter '${parameterName}' must be an array, but we got object`,
|
||||
errorOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (toType === 'string') {
|
||||
if (typeof returnData === 'object') {
|
||||
returnData = JSON.stringify(returnData);
|
||||
} else {
|
||||
returnData = String(returnData);
|
||||
}
|
||||
}
|
||||
|
||||
if (toType === 'number') {
|
||||
returnData = Number(returnData);
|
||||
if (Number.isNaN(returnData)) {
|
||||
throw new ExpressionError(
|
||||
`Parameter '${parameterName}' must be a number, but we got '${parameterValue}'`,
|
||||
errorOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (toType === 'boolean') {
|
||||
returnData = Boolean(returnData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ExpressionError) throw error;
|
||||
|
||||
throw new ExpressionError(`Parameter '${parameterName}' could not be converted to ${toType}`, {
|
||||
...errorOptions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { IRunExecutionData } from 'n8n-workflow';
|
||||
import { LoggerProxy as Logger } from 'n8n-workflow';
|
||||
|
||||
import { InvalidExecutionMetadataError } from '@/errors/invalid-execution-metadata.error';
|
||||
|
||||
export const KV_LIMIT = 10;
|
||||
|
||||
export function setWorkflowExecutionMetadata(
|
||||
executionData: IRunExecutionData,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) {
|
||||
if (!executionData.resultData.metadata) {
|
||||
executionData.resultData.metadata = {};
|
||||
}
|
||||
// Currently limited to 10 metadata KVs
|
||||
if (
|
||||
!(key in executionData.resultData.metadata) &&
|
||||
Object.keys(executionData.resultData.metadata).length >= KV_LIMIT
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (typeof key !== 'string') {
|
||||
throw new InvalidExecutionMetadataError('key', key);
|
||||
}
|
||||
if (key.replace(/[A-Za-z0-9_]/g, '').length !== 0) {
|
||||
throw new InvalidExecutionMetadataError(
|
||||
'key',
|
||||
key,
|
||||
`Custom date key can only contain characters "A-Za-z0-9_" (key "${key}")`,
|
||||
);
|
||||
}
|
||||
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') {
|
||||
throw new InvalidExecutionMetadataError('value', key);
|
||||
}
|
||||
const val = String(value);
|
||||
if (key.length > 50) {
|
||||
Logger.error('Custom data key over 50 characters long. Truncating to 50 characters.');
|
||||
}
|
||||
if (val.length > 255) {
|
||||
Logger.error('Custom data value over 255 characters long. Truncating to 255 characters.');
|
||||
}
|
||||
executionData.resultData.metadata[key.slice(0, 50)] = val.slice(0, 255);
|
||||
}
|
||||
|
||||
export function setAllWorkflowExecutionMetadata(
|
||||
executionData: IRunExecutionData,
|
||||
obj: Record<string, string>,
|
||||
) {
|
||||
const errors: Error[] = [];
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
try {
|
||||
setWorkflowExecutionMetadata(executionData, key, value);
|
||||
} catch (e) {
|
||||
errors.push(e as Error);
|
||||
}
|
||||
});
|
||||
if (errors.length) {
|
||||
throw errors[0];
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllWorkflowExecutionMetadata(
|
||||
executionData: IRunExecutionData,
|
||||
): Record<string, string> {
|
||||
// Make a copy so it can't be modified directly
|
||||
return executionData.resultData.metadata ? { ...executionData.resultData.metadata } : {};
|
||||
}
|
||||
|
||||
export function getWorkflowExecutionMetadata(
|
||||
executionData: IRunExecutionData,
|
||||
key: string,
|
||||
): string {
|
||||
return getAllWorkflowExecutionMetadata(executionData)[String(key).slice(0, 50)];
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import get from 'lodash/get';
|
||||
import {
|
||||
ApplicationError,
|
||||
LoggerProxy,
|
||||
NodeHelpers,
|
||||
NodeOperationError,
|
||||
WorkflowOperationError,
|
||||
executeFilter,
|
||||
isFilterValue,
|
||||
type INode,
|
||||
type INodeParameters,
|
||||
type INodeProperties,
|
||||
type INodePropertyCollection,
|
||||
type INodePropertyOptions,
|
||||
type INodeType,
|
||||
type NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
function findPropertyFromParameterName(
|
||||
parameterName: string,
|
||||
nodeType: INodeType,
|
||||
node: INode,
|
||||
nodeParameters: INodeParameters,
|
||||
): INodePropertyOptions | INodeProperties | INodePropertyCollection {
|
||||
let property: INodePropertyOptions | INodeProperties | INodePropertyCollection | undefined;
|
||||
const paramParts = parameterName.split('.');
|
||||
let currentParamPath = '';
|
||||
|
||||
const findProp = (
|
||||
name: string,
|
||||
options: Array<INodePropertyOptions | INodeProperties | INodePropertyCollection>,
|
||||
): INodePropertyOptions | INodeProperties | INodePropertyCollection | undefined => {
|
||||
return options.find(
|
||||
(i) =>
|
||||
i.name === name &&
|
||||
NodeHelpers.displayParameterPath(nodeParameters, i, currentParamPath, node),
|
||||
);
|
||||
};
|
||||
|
||||
for (const p of paramParts) {
|
||||
const param = p.split('[')[0];
|
||||
if (!property) {
|
||||
property = findProp(param, nodeType.description.properties);
|
||||
} else if ('options' in property && property.options) {
|
||||
property = findProp(param, property.options);
|
||||
currentParamPath += `.${param}`;
|
||||
} else if ('values' in property) {
|
||||
property = findProp(param, property.values);
|
||||
currentParamPath += `.${param}`;
|
||||
} else {
|
||||
throw new ApplicationError('Could not find property', { extra: { parameterName } });
|
||||
}
|
||||
if (!property) {
|
||||
throw new ApplicationError('Could not find property', { extra: { parameterName } });
|
||||
}
|
||||
}
|
||||
if (!property) {
|
||||
throw new ApplicationError('Could not find property', { extra: { parameterName } });
|
||||
}
|
||||
|
||||
return property;
|
||||
}
|
||||
|
||||
function executeRegexExtractValue(
|
||||
value: string,
|
||||
regex: RegExp,
|
||||
parameterName: string,
|
||||
parameterDisplayName: string,
|
||||
): NodeParameterValueType | object {
|
||||
const extracted = regex.exec(value);
|
||||
if (!extracted) {
|
||||
throw new WorkflowOperationError(
|
||||
`ERROR: ${parameterDisplayName} parameter's value is invalid. This is likely because the URL entered is incorrect`,
|
||||
);
|
||||
}
|
||||
if (extracted.length < 2 || extracted.length > 2) {
|
||||
throw new WorkflowOperationError(
|
||||
`Property "${parameterName}" has an invalid extractValue regex "${regex.source}". extractValue expects exactly one group to be returned.`,
|
||||
);
|
||||
}
|
||||
return extracted[1];
|
||||
}
|
||||
|
||||
function extractValueRLC(
|
||||
value: NodeParameterValueType | object,
|
||||
property: INodeProperties,
|
||||
parameterName: string,
|
||||
): NodeParameterValueType | object {
|
||||
// Not an RLC value
|
||||
if (typeof value !== 'object' || !value || !('mode' in value) || !('value' in value)) {
|
||||
return value;
|
||||
}
|
||||
const modeProp = (property.modes ?? []).find((i) => i.name === value.mode);
|
||||
if (!modeProp) {
|
||||
return value.value;
|
||||
}
|
||||
if (!('extractValue' in modeProp) || !modeProp.extractValue) {
|
||||
return value.value;
|
||||
}
|
||||
|
||||
if (typeof value.value !== 'string') {
|
||||
let typeName: string | undefined = value.value?.constructor.name;
|
||||
if (value.value === null) {
|
||||
typeName = 'null';
|
||||
} else if (typeName === undefined) {
|
||||
typeName = 'undefined';
|
||||
}
|
||||
LoggerProxy.error(
|
||||
`Only strings can be passed to extractValue. Parameter "${parameterName}" passed "${typeName}"`,
|
||||
);
|
||||
throw new ApplicationError(
|
||||
"ERROR: This parameter's value is invalid. Please enter a valid mode.",
|
||||
{ extra: { parameter: property.displayName, modeProp: modeProp.displayName } },
|
||||
);
|
||||
}
|
||||
|
||||
if (modeProp.extractValue.type !== 'regex') {
|
||||
throw new ApplicationError('Property with unknown `extractValue`', {
|
||||
extra: { parameter: parameterName, extractValueType: modeProp.extractValue.type },
|
||||
});
|
||||
}
|
||||
|
||||
const regex = new RegExp(modeProp.extractValue.regex);
|
||||
return executeRegexExtractValue(value.value, regex, parameterName, property.displayName);
|
||||
}
|
||||
|
||||
function extractValueFilter(
|
||||
value: NodeParameterValueType | object,
|
||||
property: INodeProperties,
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
): NodeParameterValueType | object {
|
||||
if (!isFilterValue(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (property.extractValue?.type) {
|
||||
throw new ApplicationError(
|
||||
`Property "${parameterName}" has an invalid extractValue type. Filter parameters only support extractValue: true`,
|
||||
{ extra: { parameter: parameterName } },
|
||||
);
|
||||
}
|
||||
|
||||
return executeFilter(value, { itemIndex });
|
||||
}
|
||||
|
||||
function extractValueOther(
|
||||
value: NodeParameterValueType | object,
|
||||
property: INodeProperties | INodePropertyCollection,
|
||||
parameterName: string,
|
||||
): NodeParameterValueType | object {
|
||||
if (!('extractValue' in property) || !property.extractValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
let typeName: string | undefined = value?.constructor.name;
|
||||
if (value === null) {
|
||||
typeName = 'null';
|
||||
} else if (typeName === undefined) {
|
||||
typeName = 'undefined';
|
||||
}
|
||||
LoggerProxy.error(
|
||||
`Only strings can be passed to extractValue. Parameter "${parameterName}" passed "${typeName}"`,
|
||||
);
|
||||
throw new ApplicationError("This parameter's value is invalid", {
|
||||
extra: { parameter: property.displayName },
|
||||
});
|
||||
}
|
||||
|
||||
if (property.extractValue.type !== 'regex') {
|
||||
throw new ApplicationError('Property with unknown `extractValue`', {
|
||||
extra: { parameter: parameterName, extractValueType: property.extractValue.type },
|
||||
});
|
||||
}
|
||||
|
||||
const regex = new RegExp(property.extractValue.regex);
|
||||
return executeRegexExtractValue(value, regex, parameterName, property.displayName);
|
||||
}
|
||||
|
||||
export function extractValue(
|
||||
value: NodeParameterValueType | object,
|
||||
parameterName: string,
|
||||
node: INode,
|
||||
nodeType: INodeType,
|
||||
itemIndex = 0,
|
||||
): NodeParameterValueType | object {
|
||||
let property: INodePropertyOptions | INodeProperties | INodePropertyCollection;
|
||||
try {
|
||||
property = findPropertyFromParameterName(parameterName, nodeType, node, node.parameters);
|
||||
|
||||
// Definitely doesn't have value extractor
|
||||
if (!('type' in property)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (property.type === 'resourceLocator') {
|
||||
return extractValueRLC(value, property, parameterName);
|
||||
} else if (property.type === 'filter') {
|
||||
return extractValueFilter(value, property, parameterName, itemIndex);
|
||||
}
|
||||
return extractValueOther(value, property, parameterName);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment
|
||||
throw new NodeOperationError(node, error, { description: get(error, 'description') });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
IRunExecutionData,
|
||||
IWorkflowDataProxyAdditionalKeys,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
|
||||
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants';
|
||||
|
||||
import {
|
||||
setWorkflowExecutionMetadata,
|
||||
setAllWorkflowExecutionMetadata,
|
||||
getWorkflowExecutionMetadata,
|
||||
getAllWorkflowExecutionMetadata,
|
||||
} from './execution-metadata';
|
||||
import { getSecretsProxy } from './get-secrets-proxy';
|
||||
|
||||
/** Returns the additional keys for Expressions and Function-Nodes */
|
||||
export function getAdditionalKeys(
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
runExecutionData: IRunExecutionData | null,
|
||||
options?: { secretsEnabled?: boolean },
|
||||
): IWorkflowDataProxyAdditionalKeys {
|
||||
const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID;
|
||||
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
|
||||
const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`;
|
||||
return {
|
||||
$execution: {
|
||||
id: executionId,
|
||||
mode: mode === 'manual' ? 'test' : 'production',
|
||||
resumeUrl,
|
||||
resumeFormUrl,
|
||||
customData: runExecutionData
|
||||
? {
|
||||
set(key: string, value: string): void {
|
||||
try {
|
||||
setWorkflowExecutionMetadata(runExecutionData, key, value);
|
||||
} catch (e) {
|
||||
if (mode === 'manual') {
|
||||
throw e;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
||||
LoggerProxy.debug(e.message);
|
||||
}
|
||||
},
|
||||
setAll(obj: Record<string, string>): void {
|
||||
try {
|
||||
setAllWorkflowExecutionMetadata(runExecutionData, obj);
|
||||
} catch (e) {
|
||||
if (mode === 'manual') {
|
||||
throw e;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
||||
LoggerProxy.debug(e.message);
|
||||
}
|
||||
},
|
||||
get(key: string): string {
|
||||
return getWorkflowExecutionMetadata(runExecutionData, key);
|
||||
},
|
||||
getAll(): Record<string, string> {
|
||||
return getAllWorkflowExecutionMetadata(runExecutionData);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
$vars: additionalData.variables,
|
||||
$secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined,
|
||||
|
||||
// deprecated
|
||||
$executionId: executionId,
|
||||
$resumeWebhookUrl: resumeUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
import type {
|
||||
CloseFunction,
|
||||
IExecuteData,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IRunExecutionData,
|
||||
ITaskDataConnections,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
SupplyData,
|
||||
AINodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
NodeConnectionType,
|
||||
NodeOperationError,
|
||||
ExecutionBaseError,
|
||||
ApplicationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { createNodeAsTool } from './create-node-as-tool';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { SupplyDataContext } from '../../node-execution-context';
|
||||
import type { ExecuteContext, WebhookContext } from '../../node-execution-context';
|
||||
|
||||
export async function getInputConnectionData(
|
||||
this: ExecuteContext | WebhookContext | SupplyDataContext,
|
||||
workflow: Workflow,
|
||||
runExecutionData: IRunExecutionData,
|
||||
parentRunIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
parentInputData: ITaskDataConnections,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
executeData: IExecuteData,
|
||||
mode: WorkflowExecuteMode,
|
||||
closeFunctions: CloseFunction[],
|
||||
connectionType: AINodeConnectionType,
|
||||
itemIndex: number,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const parentNode = this.getNode();
|
||||
|
||||
const inputConfiguration = this.nodeInputs.find((input) => input.type === connectionType);
|
||||
if (inputConfiguration === undefined) {
|
||||
throw new ApplicationError('Node does not have input of type', {
|
||||
extra: { nodeName: parentNode.name, connectionType },
|
||||
});
|
||||
}
|
||||
|
||||
const connectedNodes = this.getConnectedNodes(connectionType);
|
||||
if (connectedNodes.length === 0) {
|
||||
if (inputConfiguration.required) {
|
||||
throw new NodeOperationError(
|
||||
parentNode,
|
||||
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
|
||||
);
|
||||
}
|
||||
return inputConfiguration.maxConnections === 1 ? undefined : [];
|
||||
}
|
||||
|
||||
if (
|
||||
inputConfiguration.maxConnections !== undefined &&
|
||||
connectedNodes.length > inputConfiguration.maxConnections
|
||||
) {
|
||||
throw new NodeOperationError(
|
||||
parentNode,
|
||||
`Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
|
||||
);
|
||||
}
|
||||
|
||||
const nodes: SupplyData[] = [];
|
||||
for (const connectedNode of connectedNodes) {
|
||||
const connectedNodeType = workflow.nodeTypes.getByNameAndVersion(
|
||||
connectedNode.type,
|
||||
connectedNode.typeVersion,
|
||||
);
|
||||
const contextFactory = (runIndex: number, inputData: ITaskDataConnections) =>
|
||||
new SupplyDataContext(
|
||||
workflow,
|
||||
connectedNode,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
connectionType,
|
||||
executeData,
|
||||
closeFunctions,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
if (!connectedNodeType.supplyData) {
|
||||
if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) {
|
||||
/**
|
||||
* This keeps track of how many times this specific AI tool node has been invoked.
|
||||
* It is incremented on every invocation of the tool to keep the output of each invocation separate from each other.
|
||||
*/
|
||||
let toolRunIndex = 0;
|
||||
const supplyData = createNodeAsTool({
|
||||
node: connectedNode,
|
||||
nodeType: connectedNodeType,
|
||||
handleToolInvocation: async (toolArgs) => {
|
||||
const runIndex = toolRunIndex++;
|
||||
const context = contextFactory(runIndex, {});
|
||||
context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]);
|
||||
|
||||
try {
|
||||
// Execute the sub-node with the proxied context
|
||||
const result = await connectedNodeType.execute?.call(
|
||||
context as unknown as IExecuteFunctions,
|
||||
);
|
||||
|
||||
// Process and map the results
|
||||
const mappedResults = result?.[0]?.flatMap((item) => item.json);
|
||||
|
||||
// Add output data to the context
|
||||
context.addOutputData(NodeConnectionType.AiTool, runIndex, [
|
||||
[{ json: { response: mappedResults } }],
|
||||
]);
|
||||
|
||||
// Return the stringified results
|
||||
return JSON.stringify(mappedResults);
|
||||
} catch (error) {
|
||||
const nodeError = new NodeOperationError(connectedNode, error as Error);
|
||||
context.addOutputData(NodeConnectionType.AiTool, runIndex, nodeError);
|
||||
return 'Error during node execution: ' + nodeError.description;
|
||||
}
|
||||
},
|
||||
});
|
||||
nodes.push(supplyData);
|
||||
} else {
|
||||
throw new ApplicationError('Node does not have a `supplyData` method defined', {
|
||||
extra: { nodeName: connectedNode.name },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const context = contextFactory(parentRunIndex, parentInputData);
|
||||
try {
|
||||
const supplyData = await connectedNodeType.supplyData.call(context, itemIndex);
|
||||
if (supplyData.closeFunction) {
|
||||
closeFunctions.push(supplyData.closeFunction);
|
||||
}
|
||||
nodes.push(supplyData);
|
||||
} catch (error) {
|
||||
// Propagate errors from sub-nodes
|
||||
if (error instanceof ExecutionBaseError) {
|
||||
if (error.functionality === 'configuration-node') throw error;
|
||||
} else {
|
||||
error = new NodeOperationError(connectedNode, error, {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
|
||||
let currentNodeRunIndex = 0;
|
||||
if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) {
|
||||
currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length;
|
||||
}
|
||||
|
||||
// Display the error on the node which is causing it
|
||||
await context.addExecutionDataFunctions(
|
||||
'input',
|
||||
error,
|
||||
connectionType,
|
||||
parentNode.name,
|
||||
currentNodeRunIndex,
|
||||
);
|
||||
|
||||
// Display on the calling node which node has the error
|
||||
throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, {
|
||||
itemIndex,
|
||||
functionality: 'configuration-node',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inputConfiguration.maxConnections === 1
|
||||
? (nodes || [])[0]?.response
|
||||
: nodes.map((node) => node.response);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { IDataObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||
import { ExpressionError } from 'n8n-workflow';
|
||||
|
||||
function buildSecretsValueProxy(value: IDataObject): unknown {
|
||||
return new Proxy(value, {
|
||||
get(_target, valueName) {
|
||||
if (typeof valueName !== 'string') {
|
||||
return;
|
||||
}
|
||||
if (!(valueName in value)) {
|
||||
throw new ExpressionError('Could not load secrets', {
|
||||
description:
|
||||
'The credential in use tries to use secret from an external store that could not be found',
|
||||
});
|
||||
}
|
||||
const retValue = value[valueName];
|
||||
if (typeof retValue === 'object' && retValue !== null) {
|
||||
return buildSecretsValueProxy(retValue as IDataObject);
|
||||
}
|
||||
return retValue;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData): IDataObject {
|
||||
const secretsHelpers = additionalData.secretsHelpers;
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, providerName) {
|
||||
if (typeof providerName !== 'string') {
|
||||
return {};
|
||||
}
|
||||
if (secretsHelpers.hasProvider(providerName)) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target2, secretName) {
|
||||
if (typeof secretName !== 'string') {
|
||||
return;
|
||||
}
|
||||
if (!secretsHelpers.hasSecret(providerName, secretName)) {
|
||||
throw new ExpressionError('Could not load secrets', {
|
||||
description:
|
||||
'The credential in use tries to use secret from an external store that could not be found',
|
||||
});
|
||||
}
|
||||
const retValue = secretsHelpers.getSecret(providerName, secretName);
|
||||
if (typeof retValue === 'object' && retValue !== null) {
|
||||
return buildSecretsValueProxy(retValue as IDataObject);
|
||||
}
|
||||
return retValue;
|
||||
},
|
||||
set() {
|
||||
return false;
|
||||
},
|
||||
ownKeys() {
|
||||
return secretsHelpers.listSecrets(providerName);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
throw new ExpressionError('Could not load secrets', {
|
||||
description:
|
||||
'The credential in use pulls secrets from an external store that is not reachable',
|
||||
});
|
||||
},
|
||||
set() {
|
||||
return false;
|
||||
},
|
||||
ownKeys() {
|
||||
return secretsHelpers.listProviders();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
FieldType,
|
||||
IDataObject,
|
||||
INode,
|
||||
INodeProperties,
|
||||
INodePropertyCollection,
|
||||
INodePropertyOptions,
|
||||
INodeType,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ExpressionError,
|
||||
isResourceMapperValue,
|
||||
NodeHelpers,
|
||||
validateFieldType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { ExtendedValidationResult } from '@/interfaces';
|
||||
|
||||
const validateResourceMapperValue = (
|
||||
parameterName: string,
|
||||
paramValues: { [key: string]: unknown },
|
||||
node: INode,
|
||||
skipRequiredCheck = false,
|
||||
): ExtendedValidationResult => {
|
||||
const result: ExtendedValidationResult = { valid: true, newValue: paramValues };
|
||||
const paramNameParts = parameterName.split('.');
|
||||
if (paramNameParts.length !== 2) {
|
||||
return result;
|
||||
}
|
||||
const resourceMapperParamName = paramNameParts[0];
|
||||
const resourceMapperField = node.parameters[resourceMapperParamName];
|
||||
if (!resourceMapperField || !isResourceMapperValue(resourceMapperField)) {
|
||||
return result;
|
||||
}
|
||||
const schema = resourceMapperField.schema;
|
||||
const paramValueNames = Object.keys(paramValues);
|
||||
for (let i = 0; i < paramValueNames.length; i++) {
|
||||
const key = paramValueNames[i];
|
||||
const resolvedValue = paramValues[key];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
|
||||
const schemaEntry = schema.find((s) => s.id === key);
|
||||
|
||||
if (
|
||||
!skipRequiredCheck &&
|
||||
schemaEntry?.required === true &&
|
||||
schemaEntry.type !== 'boolean' &&
|
||||
(resolvedValue === undefined || resolvedValue === null)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
errorMessage: `The value "${String(key)}" is required but not set`,
|
||||
fieldName: key,
|
||||
};
|
||||
}
|
||||
|
||||
if (schemaEntry?.type) {
|
||||
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
|
||||
valueOptions: schemaEntry.options,
|
||||
strict: !resourceMapperField.attemptToConvertTypes,
|
||||
parseStrings: !!resourceMapperField.convertFieldsToString,
|
||||
});
|
||||
|
||||
if (!validationResult.valid) {
|
||||
return { ...validationResult, fieldName: key };
|
||||
} else {
|
||||
// If it's valid, set the casted value
|
||||
paramValues[key] = validationResult.newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const validateCollection = (
|
||||
node: INode,
|
||||
runIndex: number,
|
||||
itemIndex: number,
|
||||
propertyDescription: INodeProperties,
|
||||
parameterPath: string[],
|
||||
validationResult: ExtendedValidationResult,
|
||||
): ExtendedValidationResult => {
|
||||
let nestedDescriptions: INodeProperties[] | undefined;
|
||||
|
||||
if (propertyDescription.type === 'fixedCollection') {
|
||||
nestedDescriptions = (propertyDescription.options as INodePropertyCollection[]).find(
|
||||
(entry) => entry.name === parameterPath[1],
|
||||
)?.values;
|
||||
}
|
||||
|
||||
if (propertyDescription.type === 'collection') {
|
||||
nestedDescriptions = propertyDescription.options as INodeProperties[];
|
||||
}
|
||||
|
||||
if (!nestedDescriptions) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
const validationMap: {
|
||||
[key: string]: { type: FieldType; displayName: string; options?: INodePropertyOptions[] };
|
||||
} = {};
|
||||
|
||||
for (const prop of nestedDescriptions) {
|
||||
if (!prop.validateType || prop.ignoreValidationDuringExecution) continue;
|
||||
|
||||
validationMap[prop.name] = {
|
||||
type: prop.validateType,
|
||||
displayName: prop.displayName,
|
||||
options:
|
||||
prop.validateType === 'options' ? (prop.options as INodePropertyOptions[]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Object.keys(validationMap).length) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
if (validationResult.valid) {
|
||||
for (const value of Array.isArray(validationResult.newValue)
|
||||
? (validationResult.newValue as IDataObject[])
|
||||
: [validationResult.newValue as IDataObject]) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!validationMap[key]) continue;
|
||||
|
||||
const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, {
|
||||
valueOptions: validationMap[key].options,
|
||||
});
|
||||
|
||||
if (!fieldValidationResult.valid) {
|
||||
throw new ExpressionError(
|
||||
`Invalid input for field '${validationMap[key].displayName}' inside '${propertyDescription.displayName}' in [item ${itemIndex}]`,
|
||||
{
|
||||
description: fieldValidationResult.errorMessage,
|
||||
runIndex,
|
||||
itemIndex,
|
||||
nodeCause: node.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
value[key] = fieldValidationResult.newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
};
|
||||
|
||||
export const validateValueAgainstSchema = (
|
||||
node: INode,
|
||||
nodeType: INodeType,
|
||||
parameterValue: string | number | boolean | object | null | undefined,
|
||||
parameterName: string,
|
||||
runIndex: number,
|
||||
itemIndex: number,
|
||||
) => {
|
||||
const parameterPath = parameterName.split('.');
|
||||
|
||||
const propertyDescription = nodeType.description.properties.find(
|
||||
(prop) =>
|
||||
parameterPath[0] === prop.name && NodeHelpers.displayParameter(node.parameters, prop, node),
|
||||
);
|
||||
|
||||
if (!propertyDescription) {
|
||||
return parameterValue;
|
||||
}
|
||||
|
||||
let validationResult: ExtendedValidationResult = { valid: true, newValue: parameterValue };
|
||||
|
||||
if (
|
||||
parameterPath.length === 1 &&
|
||||
propertyDescription.validateType &&
|
||||
!propertyDescription.ignoreValidationDuringExecution
|
||||
) {
|
||||
validationResult = validateFieldType(
|
||||
parameterName,
|
||||
parameterValue,
|
||||
propertyDescription.validateType,
|
||||
);
|
||||
} else if (
|
||||
propertyDescription.type === 'resourceMapper' &&
|
||||
parameterPath[1] === 'value' &&
|
||||
typeof parameterValue === 'object'
|
||||
) {
|
||||
validationResult = validateResourceMapperValue(
|
||||
parameterName,
|
||||
parameterValue as { [key: string]: unknown },
|
||||
node,
|
||||
propertyDescription.typeOptions?.resourceMapper?.mode !== 'add',
|
||||
);
|
||||
} else if (['fixedCollection', 'collection'].includes(propertyDescription.type)) {
|
||||
validationResult = validateCollection(
|
||||
node,
|
||||
runIndex,
|
||||
itemIndex,
|
||||
propertyDescription,
|
||||
parameterPath,
|
||||
validationResult,
|
||||
);
|
||||
}
|
||||
|
||||
if (!validationResult.valid) {
|
||||
throw new ExpressionError(
|
||||
`Invalid input for '${
|
||||
validationResult.fieldName
|
||||
? String(validationResult.fieldName)
|
||||
: propertyDescription.displayName
|
||||
}' [item ${itemIndex}]`,
|
||||
{
|
||||
description: validationResult.errorMessage,
|
||||
runIndex,
|
||||
itemIndex,
|
||||
nodeCause: node.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return validationResult.newValue;
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type {
|
||||
AINodeConnectionType,
|
||||
CloseFunction,
|
||||
ICredentialDataDecryptedObject,
|
||||
IDataObject,
|
||||
IExecuteData,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
IRunExecutionData,
|
||||
ITaskDataConnections,
|
||||
IWebhookData,
|
||||
IWebhookFunctions,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
WebhookType,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import {
|
||||
copyBinaryFile,
|
||||
getBinaryHelperFunctions,
|
||||
getNodeWebhookUrl,
|
||||
getRequestHelperFunctions,
|
||||
returnJsonArray,
|
||||
} from '@/node-execute-functions';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
import { getInputConnectionData } from './utils/get-input-connection-data';
|
||||
|
||||
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
|
||||
readonly helpers: IWebhookFunctions['helpers'];
|
||||
|
||||
readonly nodeHelpers: IWebhookFunctions['nodeHelpers'];
|
||||
|
||||
constructor(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
private readonly webhookData: IWebhookData,
|
||||
private readonly closeFunctions: CloseFunction[],
|
||||
runExecutionData: IRunExecutionData | null,
|
||||
) {
|
||||
let connectionInputData: INodeExecutionData[] = [];
|
||||
let executionData: IExecuteData | undefined;
|
||||
|
||||
if (runExecutionData?.executionData !== undefined) {
|
||||
executionData = runExecutionData.executionData.nodeExecutionStack[0];
|
||||
if (executionData !== undefined) {
|
||||
connectionInputData = executionData.data.main[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
super(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
0,
|
||||
connectionInputData,
|
||||
executionData,
|
||||
);
|
||||
|
||||
this.helpers = {
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
};
|
||||
|
||||
this.nodeHelpers = {
|
||||
copyBinaryFile: async (filePath, fileName, mimeType) =>
|
||||
await copyBinaryFile(
|
||||
this.workflow.id,
|
||||
this.additionalData.executionId!,
|
||||
filePath,
|
||||
fileName,
|
||||
mimeType,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getCredentials<T>(type);
|
||||
}
|
||||
|
||||
getBodyData() {
|
||||
return this.assertHttpRequest().body as IDataObject;
|
||||
}
|
||||
|
||||
getHeaderData() {
|
||||
return this.assertHttpRequest().headers;
|
||||
}
|
||||
|
||||
getParamsData(): object {
|
||||
return this.assertHttpRequest().params;
|
||||
}
|
||||
|
||||
getQueryData(): object {
|
||||
return this.assertHttpRequest().query;
|
||||
}
|
||||
|
||||
getRequestObject(): Request {
|
||||
return this.assertHttpRequest();
|
||||
}
|
||||
|
||||
getResponseObject(): Response {
|
||||
if (this.additionalData.httpResponse === undefined) {
|
||||
throw new ApplicationError('Response is missing');
|
||||
}
|
||||
return this.additionalData.httpResponse;
|
||||
}
|
||||
|
||||
private assertHttpRequest() {
|
||||
const { httpRequest } = this.additionalData;
|
||||
if (httpRequest === undefined) {
|
||||
throw new ApplicationError('Request is missing');
|
||||
}
|
||||
return httpRequest;
|
||||
}
|
||||
|
||||
getNodeWebhookUrl(name: WebhookType): string | undefined {
|
||||
return getNodeWebhookUrl(
|
||||
name,
|
||||
this.workflow,
|
||||
this.node,
|
||||
this.additionalData,
|
||||
this.mode,
|
||||
this.additionalKeys,
|
||||
);
|
||||
}
|
||||
|
||||
getWebhookName() {
|
||||
return this.webhookData.webhookDescription.name;
|
||||
}
|
||||
|
||||
async getInputConnectionData(
|
||||
connectionType: AINodeConnectionType,
|
||||
itemIndex: number,
|
||||
): Promise<unknown> {
|
||||
// To be able to use expressions like "$json.sessionId" set the
|
||||
// body data the webhook received to what is normally used for
|
||||
// incoming node data.
|
||||
const connectionInputData: INodeExecutionData[] = [
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
{ json: this.additionalData.httpRequest?.body || {} },
|
||||
];
|
||||
const runExecutionData: IRunExecutionData = {
|
||||
resultData: {
|
||||
runData: {},
|
||||
},
|
||||
};
|
||||
const executeData: IExecuteData = {
|
||||
data: {
|
||||
main: [connectionInputData],
|
||||
},
|
||||
node: this.node,
|
||||
source: null,
|
||||
};
|
||||
|
||||
return await getInputConnectionData.call(
|
||||
this,
|
||||
this.workflow,
|
||||
runExecutionData,
|
||||
this.runIndex,
|
||||
connectionInputData,
|
||||
{} as ITaskDataConnections,
|
||||
this.additionalData,
|
||||
executeData,
|
||||
this.mode,
|
||||
this.closeFunctions,
|
||||
connectionType,
|
||||
itemIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
IWorkflowNodeContext,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { NodeExecutionContext } from './node-execution-context';
|
||||
|
||||
export class LoadWorkflowNodeContext extends NodeExecutionContext implements IWorkflowNodeContext {
|
||||
// Note that this differs from and does not shadow the function with the
|
||||
// same name in `NodeExecutionContext`, as it has the `itemIndex` parameter
|
||||
readonly getNodeParameter: IWorkflowNodeContext['getNodeParameter'];
|
||||
|
||||
constructor(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData) {
|
||||
super(workflow, node, additionalData, 'internal');
|
||||
{
|
||||
// We need to cast due to the overloaded IWorkflowNodeContext::getNodeParameter function
|
||||
// Which would require us to replicate all overload return types, as TypeScript offers
|
||||
// no convenient solution to refer to a set of overloads.
|
||||
this.getNodeParameter = ((
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
fallbackValue?: unknown,
|
||||
options?: IGetNodeParameterOptions,
|
||||
) =>
|
||||
this._getNodeParameter(
|
||||
parameterName,
|
||||
itemIndex,
|
||||
fallbackValue,
|
||||
options,
|
||||
)) as IWorkflowNodeContext['getNodeParameter'];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user