chore(core): Migrate task-runner from Jest to Vitest (no-changelog) (#31481)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matsu 2026-06-01 19:16:18 +03:00 committed by GitHub
parent dbe395202b
commit 2e683ffc0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 118 additions and 83 deletions

View File

@ -1,5 +0,0 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
testTimeout: 10_000,
};

View File

@ -10,9 +10,9 @@
"build": "tsc -p ./tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"format": "biome format --write src",
"format:check": "biome ci src",
"test": "jest",
"test:unit": "jest",
"test:watch": "jest --watch",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\""
@ -51,6 +51,10 @@
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@types/lodash": "catalog:"
"@n8n/vitest-config": "workspace:*",
"@types/lodash": "catalog:",
"@vitest/coverage-v8": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
}
}

View File

@ -1,6 +1,6 @@
import type { ErrorEvent } from '@sentry/core';
import { mock } from 'jest-mock-extended';
import type { ErrorReporter } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import { TaskRunnerSentry } from '../task-runner-sentry';
@ -14,7 +14,7 @@ describe('TaskRunnerSentry', () => {
};
afterEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe('filterOutUserCodeErrors', () => {

View File

@ -1,10 +1,10 @@
import { mock } from 'jest-mock-extended';
import type {
IExecuteData,
INode,
INodeExecutionData,
ITaskDataConnectionsSource,
} from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { DataRequestResponse, InputDataChunkDefinition } from '@/runner-types';
@ -32,7 +32,7 @@ describe('DataRequestResponseReconstruct', () => {
const result = reconstruct.reconstructConnectionInputItems(inputData, chunk);
expect(result).toEqual([undefined, undefined, { json: { key: 'chunked' } }, undefined]);
expect(result).toEqual([undefined, undefined, { json: { key: 'chunked' } }]);
});
it('should handle empty input data gracefully', () => {

View File

@ -19,14 +19,17 @@ export class DataRequestResponseReconstruct {
return inputItems;
}
// Only a chunk of the input items was requested. We reconstruct
// the array by filling in the missing items with `undefined`.
// Only a chunk of the input items was requested (the sender slices the data down to
// the chunk). We reconstruct the array by prefixing the chunk with `undefined` for the
// items before `startIndex`, so the chunk items land at their original indices —
// WorkflowDataProxy addresses items by position. Items after the chunk are never
// iterated, and the original total length isn't recoverable from the chunk, so we
// don't pad the tail.
let sparseInputItems: Array<INodeExecutionData | undefined> = [];
sparseInputItems = sparseInputItems
.concat(Array.from({ length: chunk.startIndex }))
.concat(inputItems)
.concat(Array.from({ length: inputItems.length - chunk.startIndex - chunk.count }));
.concat(inputItems);
return sparseInputItems;
}

View File

@ -30,7 +30,7 @@ import {
wrapIntoJson,
} from './test-data';
jest.mock('ws');
vi.mock('ws');
const defaultConfig = new MainConfig();
defaultConfig.jsRunnerConfig ??= {
@ -78,12 +78,12 @@ describe('JsTaskRunner', () => {
taskData: DataRequestResponse;
runner?: JsTaskRunner;
}) => {
jest.spyOn(runner, 'requestData').mockResolvedValue(taskData);
vi.spyOn(runner, 'requestData').mockResolvedValue(taskData);
return await runner.executeTask(task, new AbortController().signal);
};
afterEach(() => {
jest.restoreAllMocks();
vi.restoreAllMocks();
});
const executeForAllItems = async ({
@ -236,7 +236,7 @@ describe('JsTaskRunner', () => {
test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])(
'should make an rpc call for console log in %s mode',
async (nodeMode) => {
jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined);
vi.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined);
const task = newTaskParamsWithSettings({
code: "console.log('Hello', 'world!'); return {}",
nodeMode,
@ -302,7 +302,7 @@ describe('JsTaskRunner', () => {
});
it('should log the context object as [[ExecutionContext]]', async () => {
const rpcCallSpy = jest.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined);
const rpcCallSpy = vi.spyOn(defaultTaskRunner, 'makeRpcCall').mockResolvedValue(undefined);
const task = newTaskParamsWithSettings({
code: `
@ -777,7 +777,7 @@ describe('JsTaskRunner', () => {
for (const group of groups) {
it(`${group.method} for runOnceForAllItems`, async () => {
// Arrange
const rpcCallSpy = jest
const rpcCallSpy = vi
.spyOn(defaultTaskRunner, 'makeRpcCall')
.mockResolvedValue(undefined);
@ -799,7 +799,7 @@ describe('JsTaskRunner', () => {
it(`${group.method} for runOnceForEachItem`, async () => {
// Arrange
const rpcCallSpy = jest
const rpcCallSpy = vi
.spyOn(defaultTaskRunner, 'makeRpcCall')
.mockResolvedValue(undefined);
@ -1347,11 +1347,11 @@ describe('JsTaskRunner', () => {
};
runner.runningTasks.set(taskId, task);
const sendSpy = jest.spyOn(runner.ws, 'send').mockImplementation(() => {});
jest.spyOn(runner, 'sendOffers').mockImplementation(() => {});
jest
.spyOn(runner, 'requestData')
.mockResolvedValue(newDataRequestResponse([wrapIntoJson({ a: 1 })]));
const sendSpy = vi.spyOn(runner.ws, 'send').mockImplementation(() => {});
vi.spyOn(runner, 'sendOffers').mockImplementation(() => {});
vi.spyOn(runner, 'requestData').mockResolvedValue(
newDataRequestResponse([wrapIntoJson({ a: 1 })]),
);
await runner.receivedSettings(taskId, taskSettings);
@ -1374,22 +1374,22 @@ describe('JsTaskRunner', () => {
describe('idle timeout', () => {
beforeEach(() => {
jest.useFakeTimers();
vi.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
vi.useRealTimers();
});
it('should set idle timer when instantiated', () => {
const idleTimeout = 5;
const runner = createRunnerWithOpts({}, { idleTimeout });
const emitSpy = jest.spyOn(runner, 'emit');
const emitSpy = vi.spyOn(runner, 'emit');
jest.advanceTimersByTime(idleTimeout * 1000 - 100);
vi.advanceTimersByTime(idleTimeout * 1000 - 100);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout');
jest.advanceTimersByTime(idleTimeout * 1000);
vi.advanceTimersByTime(idleTimeout * 1000);
expect(emitSpy).toHaveBeenCalledWith('runner:reached-idle-timeout');
});
@ -1398,9 +1398,9 @@ describe('JsTaskRunner', () => {
const runner = createRunnerWithOpts({}, { idleTimeout });
const taskId = '123';
const offerId = 'offer123';
const emitSpy = jest.spyOn(runner, 'emit');
const emitSpy = vi.spyOn(runner, 'emit');
jest.advanceTimersByTime(idleTimeout * 1000 - 100);
vi.advanceTimersByTime(idleTimeout * 1000 - 100);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout');
runner.openOffers.set(offerId, {
@ -1409,12 +1409,12 @@ describe('JsTaskRunner', () => {
});
runner.offerAccepted(offerId, taskId);
jest.advanceTimersByTime(200);
vi.advanceTimersByTime(200);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout'); // because timer was reset
runner.runningTasks.clear();
jest.advanceTimersByTime(idleTimeout * 1000);
vi.advanceTimersByTime(idleTimeout * 1000);
expect(emitSpy).toHaveBeenCalledWith('runner:reached-idle-timeout');
});
@ -1422,28 +1422,28 @@ describe('JsTaskRunner', () => {
const idleTimeout = 5;
const runner = createRunnerWithOpts({}, { idleTimeout });
const taskId = '123';
const emitSpy = jest.spyOn(runner, 'emit');
jest.spyOn(runner, 'executeTask').mockResolvedValue({ result: [] });
const emitSpy = vi.spyOn(runner, 'emit');
vi.spyOn(runner, 'executeTask').mockResolvedValue({ result: [] });
runner.runningTasks.set(taskId, newTaskState(taskId));
jest.advanceTimersByTime(idleTimeout * 1000 - 100);
vi.advanceTimersByTime(idleTimeout * 1000 - 100);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout');
await runner.receivedSettings(taskId, {});
jest.advanceTimersByTime(200);
vi.advanceTimersByTime(200);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout'); // because timer was reset
jest.advanceTimersByTime(idleTimeout * 1000);
vi.advanceTimersByTime(idleTimeout * 1000);
expect(emitSpy).toHaveBeenCalledWith('runner:reached-idle-timeout');
});
it('should never reach idle timeout if idle timeout is set to 0', () => {
const runner = createRunnerWithOpts({}, { idleTimeout: 0 });
const emitSpy = jest.spyOn(runner, 'emit');
const emitSpy = vi.spyOn(runner, 'emit');
jest.advanceTimersByTime(999999);
vi.advanceTimersByTime(999999);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout');
});
@ -1451,12 +1451,12 @@ describe('JsTaskRunner', () => {
const idleTimeout = 5;
const runner = createRunnerWithOpts({}, { idleTimeout });
const taskId = '123';
const emitSpy = jest.spyOn(runner, 'emit');
const emitSpy = vi.spyOn(runner, 'emit');
const task = newTaskState(taskId);
runner.runningTasks.set(taskId, task);
jest.advanceTimersByTime(idleTimeout * 1000);
vi.advanceTimersByTime(idleTimeout * 1000);
expect(emitSpy).not.toHaveBeenCalledWith('runner:reached-idle-timeout');
task.cleanup();
});
@ -1764,11 +1764,11 @@ describe('JsTaskRunner', () => {
};
runner.runningTasks.set(taskId, task);
const sendSpy = jest.spyOn(runner.ws, 'send').mockImplementation(() => {});
jest.spyOn(runner, 'sendOffers').mockImplementation(() => {});
jest
.spyOn(runner, 'requestData')
.mockResolvedValue(newDataRequestResponse([wrapIntoJson({ a: 1 })]));
const sendSpy = vi.spyOn(runner.ws, 'send').mockImplementation(() => {});
vi.spyOn(runner, 'sendOffers').mockImplementation(() => {});
vi.spyOn(runner, 'requestData').mockResolvedValue(
newDataRequestResponse([wrapIntoJson({ a: 1 })]),
);
await runner.receivedSettings(taskId, taskSettings);
@ -1817,7 +1817,7 @@ describe('JsTaskRunner', () => {
});
// runCode mode doesn't fetch data, so we can pass empty response
jest.spyOn(runner, 'requestData').mockResolvedValue(newDataRequestResponse([]));
vi.spyOn(runner, 'requestData').mockResolvedValue(newDataRequestResponse([]));
return await runner.executeTask(task, new AbortController().signal);
};
@ -1958,7 +1958,7 @@ describe('JsTaskRunner', () => {
describe('console methods', () => {
it('should allow console.log without making RPC calls', async () => {
const rpcSpy = jest.spyOn(defaultTaskRunner, 'makeRpcCall');
const rpcSpy = vi.spyOn(defaultTaskRunner, 'makeRpcCall');
const outcome = await executeRunCode({
code: `

View File

@ -1,3 +1,4 @@
import type { Mock, MockInstance } from 'vitest';
import { WebSocket } from 'ws';
import { newTaskState } from '@/js-task-runner/__tests__/test-data';
@ -7,7 +8,7 @@ import type { TaskStatus } from '@/task-state';
class TestRunner extends TaskRunner {}
jest.mock('ws');
vi.mock('ws');
describe('TestRunner', () => {
let runner: TestRunner;
@ -36,7 +37,7 @@ describe('TestRunner', () => {
describe('constructor', () => {
afterEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
it('should correctly construct WebSocket URI with provided taskBrokerUri', () => {
@ -82,11 +83,11 @@ describe('TestRunner', () => {
describe('sendOffers', () => {
beforeEach(() => {
jest.useFakeTimers();
vi.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
vi.clearAllTimers();
});
it('should not send offers if canSendOffers is false', () => {
@ -94,7 +95,7 @@ describe('TestRunner', () => {
taskType: 'test-task',
maxConcurrency: 2,
});
const sendSpy = jest.spyOn(runner, 'send');
const sendSpy = vi.spyOn(runner, 'send');
expect(runner.canSendOffers).toBe(false);
runner.sendOffers();
@ -123,7 +124,7 @@ describe('TestRunner', () => {
type: 'broker:runnerregistered',
});
const sendSpy = jest.spyOn(runner, 'send');
const sendSpy = vi.spyOn(runner, 'send');
runner.sendOffers();
runner.sendOffers();
@ -159,7 +160,7 @@ describe('TestRunner', () => {
});
const taskState = newTaskState('test-task');
runner.runningTasks.set('test-task', taskState);
const sendSpy = jest.spyOn(runner, 'send');
const sendSpy = vi.spyOn(runner, 'send');
runner.sendOffers();
@ -186,13 +187,13 @@ describe('TestRunner', () => {
type: 'broker:runnerregistered',
});
const sendSpy = jest.spyOn(runner, 'send');
const sendSpy = vi.spyOn(runner, 'send');
runner.sendOffers();
expect(sendSpy).toHaveBeenCalledTimes(2);
sendSpy.mockClear();
jest.advanceTimersByTime(6000);
vi.advanceTimersByTime(6000);
runner.sendOffers();
expect(sendSpy).toHaveBeenCalledTimes(2);
});
@ -222,7 +223,7 @@ describe('TestRunner', () => {
const taskId = 'test-task';
const task = newTaskState(taskId);
const taskCleanupSpy = jest.spyOn(task, 'cleanup');
const taskCleanupSpy = vi.spyOn(task, 'cleanup');
runner.runningTasks.set(taskId, task);
await runner.taskCancelled(taskId, 'test-reason');
@ -239,20 +240,20 @@ describe('TestRunner', () => {
task.status = 'running';
runner.runningTasks.set(taskId, task);
const dataRequestReject = jest.fn();
const nodeTypesRequestReject = jest.fn();
const dataRequestReject = vi.fn();
const nodeTypesRequestReject = vi.fn();
runner.dataRequests.set('data-req', {
taskId,
requestId: 'data-req',
resolve: jest.fn(),
resolve: vi.fn(),
reject: dataRequestReject,
});
runner.nodeTypesRequests.set('node-req', {
taskId,
requestId: 'node-req',
resolve: jest.fn(),
resolve: vi.fn(),
reject: nodeTypesRequestReject,
});
@ -283,7 +284,7 @@ describe('TestRunner', () => {
const task = newTaskState(taskId);
task.status = 'waitingForSettings';
runner.runningTasks.set(taskId, task);
const sendSpy = jest.spyOn(runner, 'send');
const sendSpy = vi.spyOn(runner, 'send');
await runner.taskTimedOut(taskId);
@ -303,20 +304,20 @@ describe('TestRunner', () => {
task.status = 'running';
runner.runningTasks.set(taskId, task);
const dataRequestReject = jest.fn();
const nodeTypesRequestReject = jest.fn();
const dataRequestReject = vi.fn();
const nodeTypesRequestReject = vi.fn();
runner.dataRequests.set('data-req', {
taskId,
requestId: 'data-req',
resolve: jest.fn(),
resolve: vi.fn(),
reject: dataRequestReject,
});
runner.nodeTypesRequests.set('node-req', {
taskId,
requestId: 'node-req',
resolve: jest.fn(),
resolve: vi.fn(),
reject: nodeTypesRequestReject,
});
@ -349,7 +350,7 @@ describe('TestRunner', () => {
void runner.stop();
const sendSpy = jest.spyOn(runner, 'send');
const sendSpy = vi.spyOn(runner, 'send');
runner.offerAccepted(offerId, 'task-1');
@ -387,10 +388,10 @@ describe('TestRunner', () => {
});
describe('connection close', () => {
let processExitSpy: jest.SpyInstance;
let processExitSpy: MockInstance;
beforeEach(() => {
processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never);
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
});
afterEach(() => {
@ -401,8 +402,8 @@ describe('TestRunner', () => {
runner = newTestRunner();
// Get the close handler that was registered
const closeHandler = (runner.ws.addEventListener as jest.Mock).mock.calls.find(
([event]: [string]) => event === 'close',
const closeHandler = (runner.ws.addEventListener as unknown as Mock).mock.calls.find(
([event]: unknown[]) => event === 'close',
)?.[1] as () => void;
expect(closeHandler).toBeDefined();
@ -415,8 +416,8 @@ describe('TestRunner', () => {
runner = newTestRunner();
// Get the close handler registered via addEventListener in constructor
const closeHandler = (runner.ws.addEventListener as jest.Mock).mock.calls.find(
([event]: [string]) => event === 'close',
const closeHandler = (runner.ws.addEventListener as unknown as Mock).mock.calls.find(
([event]: unknown[]) => event === 'close',
)?.[1] as () => void;
expect(closeHandler).toBeDefined();

View File

@ -6,6 +6,7 @@
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"types": ["node", "vitest/globals"],
"paths": {
"@/*": ["./src/*"]
},

View File

@ -0,0 +1,19 @@
import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators';
import path from 'node:path';
import { mergeConfig } from 'vite';
export default mergeConfig(
createVitestConfigWithDecorators({
// The n8n root jest.config sets `restoreMocks: true`, and test files silently rely on
// it — omit this and mocks bleed between tests.
restoreMocks: true,
testTimeout: 10_000,
}),
{
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
);

View File

@ -2684,9 +2684,21 @@ importers:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@n8n/vitest-config':
specifier: workspace:*
version: link:../vitest-config
'@types/lodash':
specifier: 'catalog:'
version: 4.17.17
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.1(vitest@4.1.1)
vitest:
specifier: 'catalog:'
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
vitest-mock-extended:
specifier: 'catalog:'
version: 3.1.0(typescript@6.0.2)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
packages/@n8n/tournament:
dependencies:
@ -24632,7 +24644,7 @@ snapshots:
'@currents/commit-info': 1.0.1-beta.0
async-retry: 1.3.3
axios: 1.16.1(debug@4.4.3)
axios-retry: 4.5.0(axios@1.16.1(debug@4.4.3))
axios-retry: 4.5.0(axios@1.16.1)
chalk: 4.1.2
commander: 13.1.0
date-fns: 2.30.0
@ -28022,7 +28034,7 @@ snapshots:
'@rudderstack/rudder-sdk-node@3.0.5':
dependencies:
axios: 1.16.1(debug@4.4.3)
axios-retry: 4.5.0(axios@1.16.1(debug@4.4.3))
axios-retry: 4.5.0(axios@1.16.1)
component-type: 2.0.0
join-component: 1.1.0
lodash.clonedeep: 4.5.0
@ -31190,7 +31202,7 @@ snapshots:
axe-core@4.7.2: {}
axios-retry@4.5.0(axios@1.16.1(debug@4.4.3)):
axios-retry@4.5.0(axios@1.16.1):
dependencies:
axios: 1.16.1(debug@4.4.3)
is-retry-allowed: 2.2.0