refactor(Code Tool Node): Replace vm2 with taskrunner for js (#19247)

This commit is contained in:
yehorkardash
2025-09-09 11:15:31 +00:00
committed by GitHub
parent 04889864a0
commit a910604822
9 changed files with 182 additions and 39 deletions

View File

@@ -22,8 +22,12 @@ export class JsTaskRunnerSandbox {
private readonly jsCode: string,
private readonly nodeMode: CodeExecutionMode,
private readonly workflowMode: WorkflowExecuteMode,
private readonly executeFunctions: IExecuteFunctions,
private readonly executeFunctions: Pick<
IExecuteFunctions,
'startJob' | 'continueOnFail' | 'helpers'
>,
private readonly chunkSize = 1000,
private readonly additionalProperties: Record<string, unknown> = {},
) {}
async runCodeAllItems(): Promise<INodeExecutionData[]> {
@@ -36,6 +40,7 @@ export class JsTaskRunnerSandbox {
nodeMode: this.nodeMode,
workflowMode: this.workflowMode,
continueOnFail: this.executeFunctions.continueOnFail(),
additionalProperties: this.additionalProperties,
},
itemIndex,
);
@@ -51,6 +56,28 @@ export class JsTaskRunnerSandbox {
);
}
async runCodeForTool(): Promise<unknown> {
const itemIndex = 0;
const executionResult = await this.executeFunctions.startJob(
'javascript',
{
code: this.jsCode,
nodeMode: this.nodeMode,
workflowMode: this.workflowMode,
continueOnFail: this.executeFunctions.continueOnFail(),
additionalProperties: this.additionalProperties,
},
itemIndex,
);
if (!executionResult.ok) {
throwExecutionError('error' in executionResult ? executionResult.error : {});
}
return executionResult.result;
}
async runCodeForEachItem(numInputItems: number): Promise<INodeExecutionData[]> {
validateNoDisallowedMethodsInRunForEach(this.jsCode, 0);
@@ -70,6 +97,7 @@ export class JsTaskRunnerSandbox {
startIndex: chunk.startIdx,
count: chunk.count,
},
additionalProperties: this.additionalProperties,
},
itemIndex,
);

View File

@@ -1,6 +1,6 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import { createResultOk } from 'n8n-workflow';
import { createResultOk, createResultError } from 'n8n-workflow';
import { JsTaskRunnerSandbox } from '../JsTaskRunnerSandbox';
@@ -15,7 +15,8 @@ describe('JsTaskRunnerSandbox', () => {
...executeFunctions.helpers,
normalizeItems: jest
.fn()
.mockImplementation((items) => (Array.isArray(items) ? items : [items])),
// 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(jsCode, nodeMode, workflowMode, executeFunctions, 2);
@@ -37,6 +38,7 @@ describe('JsTaskRunnerSandbox', () => {
workflowMode,
continueOnFail: executeFunctions.continueOnFail(),
chunk: { startIndex: 0, count: 2 },
additionalProperties: {},
},
0,
],
@@ -48,6 +50,7 @@ describe('JsTaskRunnerSandbox', () => {
workflowMode,
continueOnFail: executeFunctions.continueOnFail(),
chunk: { startIndex: 2, count: 2 },
additionalProperties: {},
},
0,
],
@@ -59,10 +62,80 @@ describe('JsTaskRunnerSandbox', () => {
workflowMode,
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(jsCode, nodeMode, workflowMode, executeFunctions);
const expectedResult = 'Hello World';
executeFunctions.startJob.mockResolvedValue(createResultOk(expectedResult));
const result = await sandbox.runCodeForTool();
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 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(jsCode, nodeMode, 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()).rejects.toThrow('Execution failed');
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
});
});
});