first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { normalizeItems } from 'n8n-core';
|
||||
import type { IExecuteFunctions, INode, IWorkflowDataProxyData } from 'n8n-workflow';
|
||||
|
||||
import { Code } from '../Code.node';
|
||||
import { PythonTaskRunnerSandbox } from '../PythonTaskRunnerSandbox';
|
||||
|
||||
describe('Code Node unit test', () => {
|
||||
const workflowDataProxy = mock<IWorkflowDataProxyData>({ $input: mock() });
|
||||
|
||||
it('should route legacy `python` language to native Python runner', async () => {
|
||||
const node = new Code();
|
||||
const pythonThisArg: MockProxy<IExecuteFunctions> = mock<IExecuteFunctions>();
|
||||
pythonThisArg.helpers = { normalizeItems } as IExecuteFunctions['helpers'];
|
||||
pythonThisArg.getNode.mockReturnValue(mock<INode>({ typeVersion: 2 }));
|
||||
pythonThisArg.getWorkflowDataProxy.mockReturnValue(workflowDataProxy);
|
||||
pythonThisArg.getMode.mockReturnValue('manual');
|
||||
pythonThisArg.getRunnerStatus.mockReturnValue({ available: true });
|
||||
pythonThisArg.getNodeParameter.calledWith('language', 0).mockReturnValue('python');
|
||||
pythonThisArg.getNodeParameter.calledWith('mode', 0).mockReturnValue('runOnceForAllItems');
|
||||
pythonThisArg.getNodeParameter.calledWith('pythonCode', 0).mockReturnValue('return []');
|
||||
pythonThisArg.getInputData.mockReturnValue([{ json: {} }]);
|
||||
|
||||
const runSpy = jest
|
||||
.spyOn(PythonTaskRunnerSandbox.prototype, 'runUsingIncomingItems')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await node.execute.call(pythonThisArg);
|
||||
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
|
||||
runSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "33eede8d-2ab0-42ab-b79a-a069d8549ab0",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [-40, 580]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "return[\n { value: 1 },\n { value: 2 },\n]"
|
||||
},
|
||||
"id": "a5913b52-24dc-4f81-bb7f-f90e61dad978",
|
||||
"name": "Sample Data",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 1,
|
||||
"position": [200, 580]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nlet sum = 0;\nfor (const item of $input.all()) {\n sum += item.json.value;\n}\n\nreturn [ {sum} ];"
|
||||
},
|
||||
"id": "c4ad4913-5af3-42bc-a784-69182f1facdd",
|
||||
"name": "Run Once for All Items",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 1,
|
||||
"position": [460, 320]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nlet sum = 0;\nfor (const item of items) {\n sum += item.json.value;\n}\n\nreturn [ {sum} ];"
|
||||
},
|
||||
"id": "34cbd204-4335-4790-92cd-c3df617eee21",
|
||||
"name": "Run Once for All Items (Legacy Syntax)",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 1,
|
||||
"position": [460, 500]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "runOnceForEachItem",
|
||||
"jsCode": "// Add a new field called 'myNewField' to the\n// JSON of the item\n$input.item.json.myNewField = $input.item.json.value;\n\nreturn $input.item;"
|
||||
},
|
||||
"id": "f67d29bf-554a-4572-8867-4456182dec24",
|
||||
"name": "Run Once for Each Item",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 1,
|
||||
"position": [460, 680]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "runOnceForEachItem",
|
||||
"jsCode": "// Add a new field called 'myNewField' to the\n// JSON of the item\nitem.json.myNewField = item.json.value;\n\nreturn item;"
|
||||
},
|
||||
"id": "6f4bf149-e84e-4e0d-802a-7eaf7a42b18c",
|
||||
"name": "Run Once for Each Item (Legacy Syntax)",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 1,
|
||||
"position": [460, 860]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "runOnceForEachItem",
|
||||
"jsCode": "const json = $input.item.json\njson.myNewField = await (async () => json.value)();\n\nreturn $input.item;"
|
||||
},
|
||||
"id": "3cff4a64-c3fd-47d3-a33e-3c446846138f",
|
||||
"name": "With Async Functions",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
460,
|
||||
1200
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "runOnceForEachItem",
|
||||
"jsCode": "const json = $input.item.json\njson.myNewField = await new Promise((resolve) => resolve(json.value));\n\nreturn $input.item;"
|
||||
},
|
||||
"id": "947e4e3e-2da3-40c5-97da-830c4572fc05",
|
||||
"name": "With Promises",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
460,
|
||||
1380
|
||||
]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Run Once for All Items": [
|
||||
{
|
||||
"json": {
|
||||
"sum": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
"Run Once for Each Item": [
|
||||
{
|
||||
"json": {
|
||||
"value": 1,
|
||||
"myNewField": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"value": 2,
|
||||
"myNewField": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"Run Once for All Items (Legacy Syntax)": [
|
||||
{
|
||||
"json": {
|
||||
"sum": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
"Run Once for Each Item (Legacy Syntax)": [
|
||||
{
|
||||
"json": {
|
||||
"value": 1,
|
||||
"myNewField": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"value": 2,
|
||||
"myNewField": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"With Async Functions": [
|
||||
{
|
||||
"json": {
|
||||
"value": 1,
|
||||
"myNewField": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"value": 2,
|
||||
"myNewField": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"With Promises": [
|
||||
{
|
||||
"json": {
|
||||
"value": 1,
|
||||
"myNewField": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"value": 2,
|
||||
"myNewField": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Sample Data",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Sample Data": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Run Once for All Items",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Run Once for Each Item",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Run Once for All Items (Legacy Syntax)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Run Once for Each Item (Legacy Syntax)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "With Async Functions",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "With Promises",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ExecutionError } from '../ExecutionError';
|
||||
|
||||
describe('ExecutionError', () => {
|
||||
describe('constructor', () => {
|
||||
it('should set message to "Unknown error" when stack is empty', () => {
|
||||
const error = new Error('test');
|
||||
error.stack = '';
|
||||
const executionError = new ExecutionError(error);
|
||||
expect(executionError.message).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('should extract error details and type from stack', () => {
|
||||
const error = new Error('ErrorType: Error Details');
|
||||
error.stack = 'Error: ErrorType: Error Details\n at Code:123';
|
||||
const executionError = new ExecutionError(error);
|
||||
expect(executionError.message).toBe('Error Details [line 123]');
|
||||
expect(executionError.description).toBe('ErrorType');
|
||||
});
|
||||
|
||||
it('should extract error details when no error type is present', () => {
|
||||
const error = new Error('Error Details');
|
||||
error.stack = 'Error: Error Details\n at Code:123';
|
||||
const executionError = new ExecutionError(error);
|
||||
expect(executionError.message).toBe('Error Details [line 123]');
|
||||
expect(executionError.description).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle stack with only "Error: " prefix', () => {
|
||||
const error = new Error('Error: ');
|
||||
error.stack = 'Error: Error: \n at Code:123';
|
||||
const executionError = new ExecutionError(error);
|
||||
expect(executionError.message).toBe('Unknown error [line 123]');
|
||||
expect(executionError.description).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle stack with colon and space', () => {
|
||||
const error = new Error(': ');
|
||||
error.stack = 'Error: : \n at Code:123';
|
||||
const executionError = new ExecutionError(error);
|
||||
expect(executionError.message).toBe('Unknown error [line 123]');
|
||||
expect(executionError.description).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle itemIndex', () => {
|
||||
const error = new Error('ErrorType: Error Details');
|
||||
error.stack = 'Error: ErrorType: Error Details\n at Code:123';
|
||||
const executionError = new ExecutionError(error, 1);
|
||||
expect(executionError.message).toBe('Error Details [line 123, for item 1]');
|
||||
expect(executionError.description).toBe('ErrorType');
|
||||
expect(executionError.itemIndex).toBe(1);
|
||||
expect(executionError.context).toEqual({ itemIndex: 1 });
|
||||
});
|
||||
|
||||
it('should handle stack without line number', () => {
|
||||
const error = new Error('ErrorType: Error Details');
|
||||
error.stack = 'Error: ErrorType: Error Details';
|
||||
const executionError = new ExecutionError(error, 1);
|
||||
expect(executionError.message).toBe('Error Details');
|
||||
expect(executionError.description).toBe('ErrorType');
|
||||
expect(executionError.itemIndex).toBe(1);
|
||||
expect(executionError.context).toEqual({ itemIndex: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { validateNoDisallowedMethodsInRunForEach } from '../JsCodeValidator';
|
||||
|
||||
describe('JsCodeValidator', () => {
|
||||
describe('validateNoDisallowedMethodsInRunForEach', () => {
|
||||
it('should not throw error if disallow method is used within single line comments', () => {
|
||||
const code = [
|
||||
"// Add a new field called 'myNewField' to the JSON of the item",
|
||||
'$input.item.json.myNewField = 1;',
|
||||
' // const xxx = $input.all()',
|
||||
'return $input.item;',
|
||||
].join('\n');
|
||||
|
||||
expect(() => validateNoDisallowedMethodsInRunForEach(code, 0)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error if disallow method is used in single multi line comments', () => {
|
||||
const code = [
|
||||
"// Add a new field called 'myNewField' to the JSON of the item",
|
||||
'$input.item.json.myNewField = 1;',
|
||||
'/** const xxx = $input.all()*/',
|
||||
'return $input.item;',
|
||||
].join('\n');
|
||||
|
||||
expect(() => validateNoDisallowedMethodsInRunForEach(code, 0)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error if disallow method is used within multi line comments', () => {
|
||||
const code = [
|
||||
"// Add a new field called 'myNewField' to the JSON of the item",
|
||||
'$input.item.json.myNewField = 1;',
|
||||
'/**',
|
||||
'*const xxx = $input.all()',
|
||||
'*/',
|
||||
'return $input.item;',
|
||||
].join('\n');
|
||||
|
||||
expect(() => validateNoDisallowedMethodsInRunForEach(code, 0)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error if disallow method is used', () => {
|
||||
const code = [
|
||||
"// Add a new field called 'myNewField' to the JSON of the item",
|
||||
'$input.item.json.myNewField = 1;',
|
||||
'const xxx = $input.all()',
|
||||
'return $input.item;',
|
||||
].join('\n');
|
||||
|
||||
expect(() => validateNoDisallowedMethodsInRunForEach(code, 0)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { createResultOk, createResultError } from 'n8n-workflow';
|
||||
|
||||
import { JsTaskRunnerSandbox } from '../JsTaskRunnerSandbox';
|
||||
|
||||
describe('JsTaskRunnerSandbox', () => {
|
||||
describe('runCodeForEachItem', () => {
|
||||
it('should chunk the input items and execute the code for each chunk', async () => {
|
||||
const jsCode = 'console.log($item);';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = mock<IExecuteFunctions>();
|
||||
executeFunctions.helpers = {
|
||||
...executeFunctions.helpers,
|
||||
normalizeItems: jest
|
||||
.fn()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
.mockImplementation((items: any) => (Array.isArray(items) ? items : [items])),
|
||||
};
|
||||
|
||||
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions, 2);
|
||||
let i = 1;
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk([{ json: { item: i++ } }]));
|
||||
|
||||
const numInputItems = 5;
|
||||
await sandbox.runCodeForEachItem(jsCode, numInputItems);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledTimes(3);
|
||||
const calls = executeFunctions.startJob.mock.calls;
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
'javascript',
|
||||
{
|
||||
code: jsCode,
|
||||
workflowMode,
|
||||
nodeMode: 'runOnceForEachItem',
|
||||
continueOnFail: executeFunctions.continueOnFail(),
|
||||
chunk: { startIndex: 0, count: 2 },
|
||||
additionalProperties: {},
|
||||
},
|
||||
0,
|
||||
],
|
||||
[
|
||||
'javascript',
|
||||
{
|
||||
code: jsCode,
|
||||
workflowMode,
|
||||
nodeMode: 'runOnceForEachItem',
|
||||
continueOnFail: executeFunctions.continueOnFail(),
|
||||
chunk: { startIndex: 2, count: 2 },
|
||||
additionalProperties: {},
|
||||
},
|
||||
0,
|
||||
],
|
||||
[
|
||||
'javascript',
|
||||
{
|
||||
code: jsCode,
|
||||
workflowMode,
|
||||
nodeMode: 'runOnceForEachItem',
|
||||
continueOnFail: executeFunctions.continueOnFail(),
|
||||
chunk: { startIndex: 4, count: 1 },
|
||||
additionalProperties: {},
|
||||
},
|
||||
0,
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCodeForTool', () => {
|
||||
it('should execute code and return string result', async () => {
|
||||
const jsCode = 'return "Hello World";';
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = mock<IExecuteFunctions>();
|
||||
executeFunctions.helpers = {
|
||||
...executeFunctions.helpers,
|
||||
normalizeItems: jest
|
||||
.fn()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
.mockImplementation((items: any) => (Array.isArray(items) ? items : [items])),
|
||||
};
|
||||
|
||||
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
|
||||
|
||||
const expectedResult = 'Hello World';
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk(expectedResult));
|
||||
|
||||
const result = await sandbox.runCodeForTool(jsCode);
|
||||
|
||||
expect(result).toBe(expectedResult);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledWith(
|
||||
'javascript',
|
||||
{
|
||||
code: jsCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
continueOnFail: executeFunctions.continueOnFail(),
|
||||
additionalProperties: {},
|
||||
},
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle execution errors by calling throwExecutionError', async () => {
|
||||
const jsCode = 'throw new Error("execution failed");';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = mock<IExecuteFunctions>();
|
||||
executeFunctions.helpers = {
|
||||
...executeFunctions.helpers,
|
||||
normalizeItems: jest
|
||||
.fn()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
.mockImplementation((items: any) => (Array.isArray(items) ? items : [items])),
|
||||
};
|
||||
|
||||
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
|
||||
|
||||
const executionError = { message: 'execution failed', stack: 'error stack' };
|
||||
executeFunctions.startJob.mockResolvedValue(createResultError(executionError));
|
||||
|
||||
// Mock throwExecutionError to throw an error for testing
|
||||
const throwExecutionErrorModule = await import('../throw-execution-error');
|
||||
const throwExecutionErrorSpy = jest
|
||||
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Execution failed');
|
||||
});
|
||||
|
||||
await expect(sandbox.runCodeForTool(jsCode)).rejects.toThrow('Execution failed');
|
||||
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCode', () => {
|
||||
it('should execute code and return typed result', async () => {
|
||||
const jsCode = 'return { sorted: [3, 2, 1].sort() };';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = mock<IExecuteFunctions>();
|
||||
|
||||
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
|
||||
|
||||
const expectedResult = { sorted: [1, 2, 3] };
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk(expectedResult));
|
||||
|
||||
const result = await sandbox.runCode<{ sorted: number[] }>(jsCode);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledWith(
|
||||
'javascript',
|
||||
{
|
||||
code: jsCode,
|
||||
nodeMode: 'runCode',
|
||||
workflowMode,
|
||||
continueOnFail: executeFunctions.continueOnFail(),
|
||||
additionalProperties: {},
|
||||
},
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass additionalProperties to the job', async () => {
|
||||
const jsCode = 'return items.sort();';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = mock<IExecuteFunctions>();
|
||||
const additionalProperties = { items: [3, 1, 2], customOption: true };
|
||||
|
||||
const sandbox = new JsTaskRunnerSandbox(
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
1000,
|
||||
additionalProperties,
|
||||
);
|
||||
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk([1, 2, 3]));
|
||||
|
||||
await sandbox.runCode<number[]>(jsCode);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledWith(
|
||||
'javascript',
|
||||
{
|
||||
code: jsCode,
|
||||
nodeMode: 'runCode',
|
||||
workflowMode,
|
||||
continueOnFail: executeFunctions.continueOnFail(),
|
||||
additionalProperties,
|
||||
},
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle execution errors by calling throwExecutionError', async () => {
|
||||
const jsCode = 'throw new Error("sort failed");';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = mock<IExecuteFunctions>();
|
||||
|
||||
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
|
||||
|
||||
const executionError = { message: 'sort failed', stack: 'error stack' };
|
||||
executeFunctions.startJob.mockResolvedValue(createResultError(executionError));
|
||||
|
||||
// Mock throwExecutionError to throw an error for testing
|
||||
const throwExecutionErrorModule = await import('../throw-execution-error');
|
||||
const throwExecutionErrorSpy = jest
|
||||
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Execution failed');
|
||||
});
|
||||
|
||||
await expect(sandbox.runCode(jsCode)).rejects.toThrow('Execution failed');
|
||||
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
|
||||
});
|
||||
|
||||
it('should handle error result without error property', async () => {
|
||||
const jsCode = 'return null;';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = mock<IExecuteFunctions>();
|
||||
|
||||
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
|
||||
|
||||
// Simulate an error result without the 'error' property
|
||||
executeFunctions.startJob.mockResolvedValue({ ok: false } as ReturnType<
|
||||
typeof createResultError
|
||||
>);
|
||||
|
||||
// Mock throwExecutionError to throw an error for testing
|
||||
const throwExecutionErrorModule = await import('../throw-execution-error');
|
||||
const throwExecutionErrorSpy = jest
|
||||
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Execution failed');
|
||||
});
|
||||
|
||||
await expect(sandbox.runCode(jsCode)).rejects.toThrow('Execution failed');
|
||||
expect(throwExecutionErrorSpy).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { createResultOk, createResultError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { PythonTaskRunnerSandbox } from '../PythonTaskRunnerSandbox';
|
||||
|
||||
const createNormalizeItemsMock = () =>
|
||||
jest.fn().mockImplementation((items: any) => {
|
||||
const itemsArray = Array.isArray(items) ? items : [items];
|
||||
return itemsArray.map((item: any) => {
|
||||
if (item.json !== undefined) {
|
||||
return item;
|
||||
}
|
||||
return { json: item };
|
||||
});
|
||||
});
|
||||
|
||||
const createMockExecuteFunctions = (inputData: any[] = []) => {
|
||||
const executeFunctions = mock<IExecuteFunctions>();
|
||||
executeFunctions.helpers = {
|
||||
...executeFunctions.helpers,
|
||||
normalizeItems: createNormalizeItemsMock(),
|
||||
};
|
||||
executeFunctions.getNode.mockReturnValue({
|
||||
id: 'node-id',
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
executeFunctions.getWorkflow.mockReturnValue({
|
||||
id: 'workflow-id',
|
||||
name: 'Test Workflow',
|
||||
active: false,
|
||||
});
|
||||
executeFunctions.getInputData.mockReturnValue(inputData);
|
||||
return executeFunctions;
|
||||
};
|
||||
|
||||
describe('PythonTaskRunnerSandbox', () => {
|
||||
describe('runUsingIncomingItems', () => {
|
||||
it('should call validateRunCodeAllItems for runOnceForAllItems mode', async () => {
|
||||
const pythonCode = 'return [{"foo": "bar"}]';
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([{ json: { test: 'data' } }]);
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
pythonCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
);
|
||||
|
||||
const mockResult = [{ foo: 'bar' }];
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk(mockResult));
|
||||
|
||||
const result = await sandbox.runUsingIncomingItems();
|
||||
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledWith(
|
||||
'python',
|
||||
{
|
||||
code: pythonCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
continueOnFail: executeFunctions.continueOnFail(),
|
||||
items: [{ json: { test: 'data' } }],
|
||||
nodeId: 'node-id',
|
||||
nodeName: 'Code',
|
||||
workflowId: 'workflow-id',
|
||||
workflowName: 'Test Workflow',
|
||||
},
|
||||
0,
|
||||
);
|
||||
expect(executeFunctions.helpers.normalizeItems).toHaveBeenCalledWith(mockResult);
|
||||
expect(result).toEqual([{ json: { foo: 'bar' } }]);
|
||||
});
|
||||
|
||||
it('should call validateRunCodeEachItem for runOnceForEachItem mode', async () => {
|
||||
const pythonCode = 'return {"foo": "bar"}';
|
||||
const nodeMode = 'runOnceForEachItem';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([
|
||||
{ json: { test: 'data1' } },
|
||||
{ json: { test: 'data2' } },
|
||||
]);
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
pythonCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
);
|
||||
|
||||
const mockResult = [
|
||||
{ json: { foo: 'bar' }, pairedItem: { item: 0 } },
|
||||
{ json: { foo: 'bar' }, pairedItem: { item: 1 } },
|
||||
];
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk(mockResult));
|
||||
|
||||
const result = await sandbox.runUsingIncomingItems();
|
||||
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
|
||||
expect(executeFunctions.helpers.normalizeItems).toHaveBeenCalledTimes(2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty('json');
|
||||
expect(result[0]).toHaveProperty('pairedItem');
|
||||
});
|
||||
|
||||
it('should handle execution errors by calling throwExecutionError', async () => {
|
||||
const pythonCode = 'raise ValueError("test error")';
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([]);
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
pythonCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
);
|
||||
|
||||
const executionError = { message: 'test error', stack: 'error stack' };
|
||||
executeFunctions.startJob.mockResolvedValue(createResultError(executionError));
|
||||
|
||||
const throwExecutionErrorModule = await import('../throw-execution-error');
|
||||
const throwExecutionErrorSpy = jest
|
||||
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Execution failed');
|
||||
});
|
||||
|
||||
await expect(sandbox.runUsingIncomingItems()).rejects.toThrow('Execution failed');
|
||||
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when pythonCode is undefined', async () => {
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([]);
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
undefined as unknown as string,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
);
|
||||
|
||||
await expect(sandbox.runUsingIncomingItems()).rejects.toThrow(NodeOperationError);
|
||||
await expect(sandbox.runUsingIncomingItems()).rejects.toThrow(
|
||||
'No Python code found to execute',
|
||||
);
|
||||
expect(executeFunctions.startJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCodeForTool', () => {
|
||||
it('should pass query and empty items to the runner', async () => {
|
||||
const pythonCode = 'return _query.upper()';
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([]);
|
||||
const query = 'hello world';
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
pythonCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
{ query },
|
||||
);
|
||||
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk('HELLO WORLD'));
|
||||
|
||||
const result = await sandbox.runCodeForTool();
|
||||
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledWith(
|
||||
'python',
|
||||
{
|
||||
code: pythonCode,
|
||||
nodeMode: 'runOnceForAllItems',
|
||||
workflowMode,
|
||||
continueOnFail: executeFunctions.continueOnFail(),
|
||||
items: [],
|
||||
nodeId: 'node-id',
|
||||
nodeName: 'Code',
|
||||
workflowId: 'workflow-id',
|
||||
workflowName: 'Test Workflow',
|
||||
query,
|
||||
},
|
||||
0,
|
||||
);
|
||||
expect(result).toBe('HELLO WORLD');
|
||||
});
|
||||
|
||||
it('should pass structured query object to the runner', async () => {
|
||||
const pythonCode = 'return f"{_query["name"]} is {_query["age"]}"';
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([]);
|
||||
const query = { name: 'Alice', age: 30 };
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
pythonCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
{ query },
|
||||
);
|
||||
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk('Alice is 30'));
|
||||
|
||||
const result = await sandbox.runCodeForTool();
|
||||
|
||||
expect(executeFunctions.startJob).toHaveBeenCalledWith(
|
||||
'python',
|
||||
expect.objectContaining({ query, items: [] }),
|
||||
0,
|
||||
);
|
||||
expect(result).toBe('Alice is 30');
|
||||
});
|
||||
|
||||
it('should return result without validation', async () => {
|
||||
const pythonCode = 'return 42';
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([]);
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
pythonCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
{ query: 'test' },
|
||||
);
|
||||
|
||||
executeFunctions.startJob.mockResolvedValue(createResultOk(42));
|
||||
|
||||
const result = await sandbox.runCodeForTool();
|
||||
|
||||
// Should return raw number, not wrapped in INodeExecutionData
|
||||
expect(result).toBe(42);
|
||||
expect(executeFunctions.helpers.normalizeItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle execution errors by calling throwExecutionError', async () => {
|
||||
const pythonCode = 'raise ValueError("tool error")';
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([]);
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
pythonCode,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
{ query: 'test' },
|
||||
);
|
||||
|
||||
const executionError = { message: 'tool error', stack: 'error stack' };
|
||||
executeFunctions.startJob.mockResolvedValue(createResultError(executionError));
|
||||
|
||||
const throwExecutionErrorModule = await import('../throw-execution-error');
|
||||
const throwExecutionErrorSpy = jest
|
||||
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Tool execution failed');
|
||||
});
|
||||
|
||||
await expect(sandbox.runCodeForTool()).rejects.toThrow('Tool execution failed');
|
||||
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when pythonCode is undefined', async () => {
|
||||
const nodeMode = 'runOnceForAllItems';
|
||||
const workflowMode = 'manual';
|
||||
const executeFunctions = createMockExecuteFunctions([]);
|
||||
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
undefined as unknown as string,
|
||||
nodeMode,
|
||||
workflowMode,
|
||||
executeFunctions,
|
||||
{ query: 'test' },
|
||||
);
|
||||
|
||||
await expect(sandbox.runCodeForTool()).rejects.toThrow(NodeOperationError);
|
||||
await expect(sandbox.runCodeForTool()).rejects.toThrow('No Python code found to execute');
|
||||
expect(executeFunctions.startJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { addPostExecutionWarning } from '../utils';
|
||||
|
||||
describe('addPostExecutionWarning', () => {
|
||||
const context = mock<IExecuteFunctions>();
|
||||
const inputItemsLength = 2;
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
it('should add execution hints when returnData length differs from inputItemsLength', () => {
|
||||
const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }];
|
||||
|
||||
addPostExecutionWarning(context, returnData, inputItemsLength);
|
||||
|
||||
expect(context.addExecutionHints).toHaveBeenCalledWith({
|
||||
message:
|
||||
'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
|
||||
location: 'outputPane',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add execution hints when any item has undefined pairedItem', () => {
|
||||
const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }, { json: {} }];
|
||||
|
||||
addPostExecutionWarning(context, returnData, inputItemsLength);
|
||||
|
||||
expect(context.addExecutionHints).toHaveBeenCalledWith({
|
||||
message:
|
||||
'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
|
||||
location: 'outputPane',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add execution hints when all items match inputItemsLength and have defined pairedItem', () => {
|
||||
const returnData: INodeExecutionData[] = [
|
||||
{ json: {}, pairedItem: 0 },
|
||||
{ json: {}, pairedItem: 1 },
|
||||
];
|
||||
|
||||
addPostExecutionWarning(context, returnData, inputItemsLength);
|
||||
|
||||
expect(context.addExecutionHints).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "errors falsely flagged as internal",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "d2ff695c-4ca0-457d-ae9e-666d2dc53a53",
|
||||
"name": "When clicking ‘Execute workflow’",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"position": [360, 420],
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "return {json: \"test\"}"
|
||||
},
|
||||
"id": "fc540f62-d671-49a2-b35d-aead4ed8bd10",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [620, 420],
|
||||
"onError": "continueErrorOutput"
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "d8335277-61af-42d8-9cf5-02a8b85df42b",
|
||||
"name": "No Operation, do nothing",
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [900, 500]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing": [
|
||||
{
|
||||
"json": {
|
||||
"error": "A 'json' property isn't an object [item 0]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[],
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "1aa448f4-ac5f-497b-ae3c-62a5e9db63d4",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "be251a83c052a9862eeac953816fbb1464f89dfbf79d7ac490a8e336a8cc8bfd"
|
||||
},
|
||||
"id": "TlJvElz9tvmByCh3",
|
||||
"tags": []
|
||||
}
|
||||
Reference in New Issue
Block a user