From 73c02cb1c2c2ff48efc2e8bdae2b451dec6c21d1 Mon Sep 17 00:00:00 2001 From: Matsu Date: Tue, 2 Jun 2026 12:17:03 +0300 Subject: [PATCH] chore(core): Migrate @n8n/agents from Jest to Vitest (no-changelog) (#31529) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/@n8n/agents/jest.config.js | 7 - packages/@n8n/agents/package.json | 12 +- .../src/__tests__/langsmith-telemetry.test.ts | 62 +++---- .../workspace/base-filesystem.test.ts | 10 +- .../__tests__/workspace/base-sandbox.test.ts | 28 +-- .../src/__tests__/workspace/lifecycle.test.ts | 22 +-- .../workspace/workspace-tools.test.ts | 34 ++-- .../src/__tests__/workspace/workspace.test.ts | 54 +++--- .../runtime/__tests__/agent-runtime.test.ts | 170 ++++++++---------- .../episodic-memory-defaults.test.ts | 16 +- .../runtime/__tests__/episodic-memory.test.ts | 32 ++-- .../runtime/__tests__/mcp-connection.test.ts | 36 ++-- .../runtime/__tests__/model-factory.test.ts | 34 ++-- .../observation-log-observer.test.ts | 16 +- .../observation-log-reflector.test.ts | 16 +- .../scoped-memory-task-runner.test.ts | 15 +- .../__tests__/title-generation.test.ts | 14 +- .../runtime/__tests__/tool-adapter.test.ts | 10 +- .../src/sdk/__tests__/agent-telemetry.test.ts | 23 +-- .../__tests__/memory-builder-episodic.test.ts | 14 +- .../memory-builder-observational.test.ts | 12 +- .../src/sdk/__tests__/telemetry.test.ts | 25 ++- .../agents/src/sdk/__tests__/tool.test.ts | 7 +- .../skills/__tests__/runtime-skills.test.ts | 14 +- packages/@n8n/agents/tsconfig.json | 2 +- packages/@n8n/agents/vite.config.ts | 64 +++++++ pnpm-lock.yaml | 12 ++ 27 files changed, 416 insertions(+), 345 deletions(-) delete mode 100644 packages/@n8n/agents/jest.config.js create mode 100644 packages/@n8n/agents/vite.config.ts diff --git a/packages/@n8n/agents/jest.config.js b/packages/@n8n/agents/jest.config.js deleted file mode 100644 index d1e95f01ebc..00000000000 --- a/packages/@n8n/agents/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {import('jest').Config} */ -const base = require('../../../jest.config'); - -module.exports = { - ...base, - testPathIgnorePatterns: [...(base.testPathIgnorePatterns || []), '/integration/'], -}; diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index da8ea957552..a8618ad1614 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -43,9 +43,9 @@ "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", - "test": "jest", - "test:unit": "jest", - "test:dev": "jest --watch", + "test": "vitest run", + "test:unit": "vitest run", + "test:dev": "vitest --silent=false", "test:integration": "vitest run --config vitest.integration.config.mjs" }, "dependencies": { @@ -91,6 +91,10 @@ }, "devDependencies": { "@n8n/typescript-config": "workspace:*", - "@types/json-schema": "^7.0.15" + "@n8n/vitest-config": "workspace:*", + "@types/json-schema": "^7.0.15", + "@vitest/coverage-v8": "catalog:", + "vitest": "catalog:", + "vitest-mock-extended": "catalog:" } } diff --git a/packages/@n8n/agents/src/__tests__/langsmith-telemetry.test.ts b/packages/@n8n/agents/src/__tests__/langsmith-telemetry.test.ts index ba98c91a59c..4c6bba81a56 100644 --- a/packages/@n8n/agents/src/__tests__/langsmith-telemetry.test.ts +++ b/packages/@n8n/agents/src/__tests__/langsmith-telemetry.test.ts @@ -3,71 +3,73 @@ type MockExportResult = { code: number; error?: Error }; type MockExportCallback = (result: MockExportResult) => void; type MockExporter = { type: 'exporter'; - export: jest.Mock; - shutdown: jest.Mock, []>; + export: Mock<(...args: [unknown[], MockExportCallback]) => void>; + shutdown: Mock<(...args: []) => Promise>; }; const mockExporterInstances: MockExporter[] = []; const mockBatchProcessorInputs: unknown[] = []; const mockBatchProcessorInstances: Array<{ - forceFlush: jest.Mock, []>; - onStart: jest.Mock; - onEnd: jest.Mock; - shutdown: jest.Mock, []>; + forceFlush: Mock<(...args: []) => Promise>; + onStart: Mock<(...args: [unknown, unknown]) => void>; + onEnd: Mock<(...args: [unknown]) => void>; + shutdown: Mock<(...args: []) => Promise>; }> = []; const mockProviderConfigs: unknown[] = []; -const mockAwaitPendingTraceBatches = jest.fn(async () => await Promise.resolve()); -const mockTracer = { startSpan: jest.fn() }; +const mockAwaitPendingTraceBatches = vi.fn(async () => await Promise.resolve()); +const mockTracer = { startSpan: vi.fn() }; const mockProvider = { - getTracer: jest.fn(() => mockTracer), - register: jest.fn(), - forceFlush: jest.fn(), - shutdown: jest.fn(), + getTracer: vi.fn(() => mockTracer), + register: vi.fn(), + forceFlush: vi.fn(), + shutdown: vi.fn(), }; -jest.mock('langsmith/experimental/otel/exporter', () => ({ - LangSmithOTLPTraceExporter: jest.fn((config: unknown) => { +vi.mock('langsmith/experimental/otel/exporter', () => ({ + LangSmithOTLPTraceExporter: vi.fn(function (config: unknown) { mockExporterConfigs.push(config); const exporter: MockExporter = { type: 'exporter', - export: jest.fn((_: unknown[], resultHandler: MockExportCallback) => { + export: vi.fn((_: unknown[], resultHandler: MockExportCallback) => { resultHandler({ code: 0 }); }), - shutdown: jest.fn(async () => await Promise.resolve()), + shutdown: vi.fn(async () => await Promise.resolve()), }; mockExporterInstances.push(exporter); return exporter; }), })); -jest.mock('@opentelemetry/sdk-trace-base', () => ({ - BatchSpanProcessor: jest.fn((exporter: unknown) => { +vi.mock('@opentelemetry/sdk-trace-base', () => ({ + BatchSpanProcessor: vi.fn(function (exporter: unknown) { mockBatchProcessorInputs.push(exporter); const processor = { - forceFlush: jest.fn(async () => await Promise.resolve()), - onStart: jest.fn(), - onEnd: jest.fn(), - shutdown: jest.fn(async () => await Promise.resolve()), + forceFlush: vi.fn(async () => await Promise.resolve()), + onStart: vi.fn(), + onEnd: vi.fn(), + shutdown: vi.fn(async () => await Promise.resolve()), }; mockBatchProcessorInstances.push(processor); return processor; }), })); -jest.mock('langsmith', () => ({ +vi.mock('langsmith', () => ({ RunTree: { - getSharedClient: jest.fn(() => ({ + getSharedClient: vi.fn(() => ({ awaitPendingTraceBatches: mockAwaitPendingTraceBatches, })), }, })); -jest.mock('@opentelemetry/sdk-trace-node', () => ({ - NodeTracerProvider: jest.fn((config: unknown) => { +vi.mock('@opentelemetry/sdk-trace-node', () => ({ + NodeTracerProvider: vi.fn(function (config: unknown) { mockProviderConfigs.push(config); return mockProvider; }), })); +import type { Mock } from 'vitest'; + import { LangSmithTelemetry } from '../integrations/langsmith'; describe('LangSmithTelemetry', () => { @@ -131,8 +133,8 @@ describe('LangSmithTelemetry', () => { }); it('resolves function headers for every export request', async () => { - const getHeaders = jest - .fn>, []>() + const getHeaders = vi + .fn<(...args: []) => Promise>>() .mockResolvedValueOnce({ Authorization: 'Bearer proxy-token-1' }) .mockResolvedValueOnce({ Authorization: 'Bearer proxy-token-2' }); @@ -186,8 +188,8 @@ describe('LangSmithTelemetry', () => { it('reports export failure when function headers reject', async () => { const refreshError = new Error('could not refresh headers'); - const getHeaders = jest - .fn>, []>() + const getHeaders = vi + .fn<(...args: []) => Promise>>() .mockRejectedValueOnce(refreshError); await new LangSmithTelemetry({ diff --git a/packages/@n8n/agents/src/__tests__/workspace/base-filesystem.test.ts b/packages/@n8n/agents/src/__tests__/workspace/base-filesystem.test.ts index 0753c3dc4bf..1cb71d4143f 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/base-filesystem.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/base-filesystem.test.ts @@ -18,8 +18,8 @@ class TestFilesystem extends BaseFilesystem { readonly provider = 'test'; status: ProviderStatus = 'pending'; - initFn = jest.fn().mockResolvedValue(undefined); - destroyFn = jest.fn().mockResolvedValue(undefined); + initFn = vi.fn().mockResolvedValue(undefined); + destroyFn = vi.fn().mockResolvedValue(undefined); constructor(id: string, options?: BaseFilesystemOptions) { super(options); @@ -176,7 +176,7 @@ describe('BaseFilesystem', () => { describe('lifecycle hooks', () => { it('calls onInit hook after successful init', async () => { - const onInit = jest.fn(); + const onInit = vi.fn(); const fs = new TestFilesystem('1', { onInit }); await fs._init(); @@ -185,7 +185,7 @@ describe('BaseFilesystem', () => { }); it('does not fail when onInit hook throws', async () => { - const onInit = jest.fn().mockRejectedValue(new Error('hook err')); + const onInit = vi.fn().mockRejectedValue(new Error('hook err')); const fs = new TestFilesystem('1', { onInit }); await fs._init(); @@ -194,7 +194,7 @@ describe('BaseFilesystem', () => { }); it('calls onDestroy hook during destroy', async () => { - const onDestroy = jest.fn(); + const onDestroy = vi.fn(); const fs = new TestFilesystem('1', { onDestroy }); await fs._init(); diff --git a/packages/@n8n/agents/src/__tests__/workspace/base-sandbox.test.ts b/packages/@n8n/agents/src/__tests__/workspace/base-sandbox.test.ts index 0786e62b21a..537a705457a 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/base-sandbox.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/base-sandbox.test.ts @@ -1,3 +1,5 @@ +import type { Mock } from 'vitest'; + import { BaseSandbox } from '../../workspace/sandbox/base-sandbox'; import type { CommandResult, @@ -40,17 +42,17 @@ class StubProcessHandle extends ProcessHandle { } function makeStubProcessManager(): SandboxProcessManager & { - spawnMock: jest.Mock; + spawnMock: Mock; } { const handle = new StubProcessHandle(1); - const spawnMock = jest.fn().mockResolvedValue(handle); + const spawnMock = vi.fn().mockResolvedValue(handle); return { spawn: spawnMock, - list: jest.fn().mockResolvedValue([]), - get: jest.fn().mockResolvedValue(undefined), - kill: jest.fn().mockResolvedValue(false), + list: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(undefined), + kill: vi.fn().mockResolvedValue(false), spawnMock, - } as unknown as SandboxProcessManager & { spawnMock: jest.Mock }; + } as unknown as SandboxProcessManager & { spawnMock: Mock }; } class TestSandbox extends BaseSandbox { @@ -58,9 +60,9 @@ class TestSandbox extends BaseSandbox { readonly name: string; readonly provider = 'test'; - startFn = jest.fn().mockResolvedValue(undefined); - stopFn = jest.fn().mockResolvedValue(undefined); - destroyFn = jest.fn().mockResolvedValue(undefined); + startFn = vi.fn().mockResolvedValue(undefined); + stopFn = vi.fn().mockResolvedValue(undefined); + destroyFn = vi.fn().mockResolvedValue(undefined); constructor(id: string, options?: BaseSandboxOptions) { super(options); @@ -215,7 +217,7 @@ describe('BaseSandbox', () => { describe('lifecycle hooks', () => { it('calls onStart hook after successful start', async () => { - const onStart = jest.fn(); + const onStart = vi.fn(); const sb = new TestSandbox('1', { onStart }); await sb._start(); @@ -224,7 +226,7 @@ describe('BaseSandbox', () => { }); it('does not fail when onStart hook throws', async () => { - const onStart = jest.fn().mockRejectedValue(new Error('hook error')); + const onStart = vi.fn().mockRejectedValue(new Error('hook error')); const sb = new TestSandbox('1', { onStart }); await sb._start(); @@ -233,7 +235,7 @@ describe('BaseSandbox', () => { }); it('calls onStop hook before stopping', async () => { - const onStop = jest.fn(); + const onStop = vi.fn(); const sb = new TestSandbox('1', { onStop }); await sb._start(); @@ -243,7 +245,7 @@ describe('BaseSandbox', () => { }); it('calls onDestroy hook before destroying', async () => { - const onDestroy = jest.fn(); + const onDestroy = vi.fn(); const sb = new TestSandbox('1', { onDestroy }); await sb._start(); diff --git a/packages/@n8n/agents/src/__tests__/workspace/lifecycle.test.ts b/packages/@n8n/agents/src/__tests__/workspace/lifecycle.test.ts index fc6eb35ccdc..ed6b37ab2bc 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/lifecycle.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/lifecycle.test.ts @@ -3,8 +3,8 @@ import { callLifecycle } from '../../workspace/lifecycle'; describe('callLifecycle', () => { it('calls _init when both _init and init exist', async () => { const target = { - _init: jest.fn().mockResolvedValue(undefined), - init: jest.fn().mockResolvedValue(undefined), + _init: vi.fn().mockResolvedValue(undefined), + init: vi.fn().mockResolvedValue(undefined), }; await callLifecycle(target, 'init'); @@ -15,7 +15,7 @@ describe('callLifecycle', () => { it('falls back to init when _init is undefined', async () => { const target = { - init: jest.fn().mockResolvedValue(undefined), + init: vi.fn().mockResolvedValue(undefined), }; await callLifecycle(target, 'init'); @@ -25,8 +25,8 @@ describe('callLifecycle', () => { it('calls _start when both _start and start exist', async () => { const target = { - _start: jest.fn().mockResolvedValue(undefined), - start: jest.fn().mockResolvedValue(undefined), + _start: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue(undefined), }; await callLifecycle(target, 'start'); @@ -37,8 +37,8 @@ describe('callLifecycle', () => { it('calls _stop over stop', async () => { const target = { - _stop: jest.fn().mockResolvedValue(undefined), - stop: jest.fn().mockResolvedValue(undefined), + _stop: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), }; await callLifecycle(target, 'stop'); @@ -49,8 +49,8 @@ describe('callLifecycle', () => { it('calls _destroy over destroy', async () => { const target = { - _destroy: jest.fn().mockResolvedValue(undefined), - destroy: jest.fn().mockResolvedValue(undefined), + _destroy: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), }; await callLifecycle(target, 'destroy'); @@ -68,7 +68,7 @@ describe('callLifecycle', () => { it('propagates errors from lifecycle methods', async () => { const error = new Error('lifecycle failure'); const target = { - _start: jest.fn().mockRejectedValue(error), + _start: vi.fn().mockRejectedValue(error), }; await expect(callLifecycle(target, 'start')).rejects.toThrow('lifecycle failure'); @@ -78,7 +78,7 @@ describe('callLifecycle', () => { const target = { value: 42, // eslint-disable-next-line @typescript-eslint/require-await - _init: jest.fn(async function (this: { value: number }) { + _init: vi.fn(async function (this: { value: number }) { expect(this.value).toBe(42); }), }; diff --git a/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts b/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts index b25cfb599c8..2b74bd827a0 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts @@ -8,20 +8,20 @@ function makeFakeFilesystem(overrides: Partial = {}): Works name: 'TestFS', provider: 'test', status: 'ready', - readFile: jest.fn().mockResolvedValue('file content'), - writeFile: jest.fn().mockResolvedValue(undefined), - appendFile: jest.fn().mockResolvedValue(undefined), - deleteFile: jest.fn().mockResolvedValue(undefined), - copyFile: jest.fn().mockResolvedValue(undefined), - moveFile: jest.fn().mockResolvedValue(undefined), - mkdir: jest.fn().mockResolvedValue(undefined), - rmdir: jest.fn().mockResolvedValue(undefined), - readdir: jest.fn().mockResolvedValue([ + readFile: vi.fn().mockResolvedValue('file content'), + writeFile: vi.fn().mockResolvedValue(undefined), + appendFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), + moveFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + readdir: vi.fn().mockResolvedValue([ { name: 'file1.txt', type: 'file' as const }, { name: 'subdir', type: 'directory' as const }, ]), - exists: jest.fn().mockResolvedValue(true), - stat: jest.fn().mockResolvedValue({ + exists: vi.fn().mockResolvedValue(true), + stat: vi.fn().mockResolvedValue({ name: 'test.txt', path: '/test.txt', type: 'file' as const, @@ -46,7 +46,7 @@ function makeFakeSandbox(overrides: Partial = {}): WorkspaceSa name: 'TestSandbox', provider: 'test', status: 'running', - executeCommand: jest.fn().mockResolvedValue(mockResult), + executeCommand: vi.fn().mockResolvedValue(mockResult), ...overrides, }; } @@ -131,7 +131,7 @@ describe('createWorkspaceTools', () => { it('str_replace_file handler reads then writes changed content', async () => { const fs = makeFakeFilesystem({ - readFile: jest.fn().mockResolvedValue('first\nsecond'), + readFile: vi.fn().mockResolvedValue('first\nsecond'), }); const tools = createWorkspaceTools({ filesystem: fs }); const strReplaceTool = tools.find((t) => t.name === 'workspace_str_replace_file')!; @@ -153,7 +153,7 @@ describe('createWorkspaceTools', () => { it('str_replace_file handler returns errors without writing when replacement is not unique', async () => { const fs = makeFakeFilesystem({ - readFile: jest.fn().mockResolvedValue('same\nsame'), + readFile: vi.fn().mockResolvedValue('same\nsame'), }); const tools = createWorkspaceTools({ filesystem: fs }); const strReplaceTool = tools.find((t) => t.name === 'workspace_str_replace_file')!; @@ -176,7 +176,7 @@ describe('createWorkspaceTools', () => { it('batch_str_replace_file handler applies all replacements atomically', async () => { const fs = makeFakeFilesystem({ - readFile: jest.fn().mockResolvedValue('const a = 1;\nconst b = 2;'), + readFile: vi.fn().mockResolvedValue('const a = 1;\nconst b = 2;'), }); const tools = createWorkspaceTools({ filesystem: fs }); const batchStrReplaceTool = tools.find((t) => t.name === 'workspace_batch_str_replace_file')!; @@ -203,7 +203,7 @@ describe('createWorkspaceTools', () => { it('batch_str_replace_file handler does not write when any replacement fails', async () => { const fs = makeFakeFilesystem({ - readFile: jest.fn().mockResolvedValue('const a = 1;\nconst b = 2;'), + readFile: vi.fn().mockResolvedValue('const a = 1;\nconst b = 2;'), }); const tools = createWorkspaceTools({ filesystem: fs }); const batchStrReplaceTool = tools.find((t) => t.name === 'workspace_batch_str_replace_file')!; @@ -251,7 +251,7 @@ describe('createWorkspaceTools', () => { }); it('execute_command handler includes sandbox default command environment', async () => { - const executeCommand = jest.fn().mockResolvedValue({ + const executeCommand = vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: 'ok', diff --git a/packages/@n8n/agents/src/__tests__/workspace/workspace.test.ts b/packages/@n8n/agents/src/__tests__/workspace/workspace.test.ts index d39c78d956e..a5121d7ceb0 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/workspace.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/workspace.test.ts @@ -1,3 +1,5 @@ +import type { Mock } from 'vitest'; + import type { WorkspaceFilesystem, WorkspaceSandbox } from '../../workspace/types'; import { Workspace } from '../../workspace/workspace'; @@ -7,17 +9,17 @@ function makeFakeFilesystem(overrides: Partial = {}): Works name: 'TestFS', provider: 'test', status: 'pending', - readFile: jest.fn(), - writeFile: jest.fn(), - appendFile: jest.fn(), - deleteFile: jest.fn(), - copyFile: jest.fn(), - moveFile: jest.fn(), - mkdir: jest.fn(), - rmdir: jest.fn(), - readdir: jest.fn(), - exists: jest.fn(), - stat: jest.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + appendFile: vi.fn(), + deleteFile: vi.fn(), + copyFile: vi.fn(), + moveFile: vi.fn(), + mkdir: vi.fn(), + rmdir: vi.fn(), + readdir: vi.fn(), + exists: vi.fn(), + stat: vi.fn(), ...overrides, }; } @@ -87,13 +89,13 @@ describe('Workspace', () => { it('calls filesystem._init then sandbox._start', async () => { const order: string[] = []; const fs = makeFakeFilesystem({ - _init: jest.fn(async () => { + _init: vi.fn(async () => { await Promise.resolve(); order.push('fs-init'); }), }); const sb = makeFakeSandbox({ - _start: jest.fn(async () => { + _start: vi.fn(async () => { await Promise.resolve(); order.push('sb-start'); }), @@ -114,7 +116,7 @@ describe('Workspace', () => { it('initializes only filesystem when no sandbox', async () => { const fs = makeFakeFilesystem({ - _init: jest.fn().mockResolvedValue(undefined), + _init: vi.fn().mockResolvedValue(undefined), }); const ws = new Workspace({ filesystem: fs }); @@ -126,7 +128,7 @@ describe('Workspace', () => { it('starts only sandbox when no filesystem', async () => { const sb = makeFakeSandbox({ - _start: jest.fn().mockResolvedValue(undefined), + _start: vi.fn().mockResolvedValue(undefined), }); const ws = new Workspace({ sandbox: sb }); @@ -138,11 +140,11 @@ describe('Workspace', () => { it('destroys filesystem and sets error status when sandbox start fails', async () => { const fs = makeFakeFilesystem({ - _init: jest.fn().mockResolvedValue(undefined), - _destroy: jest.fn().mockResolvedValue(undefined), + _init: vi.fn().mockResolvedValue(undefined), + _destroy: vi.fn().mockResolvedValue(undefined), }); const sb = makeFakeSandbox({ - _start: jest.fn().mockRejectedValue(new Error('sandbox start failed')), + _start: vi.fn().mockRejectedValue(new Error('sandbox start failed')), }); const ws = new Workspace({ filesystem: fs, sandbox: sb }); @@ -155,12 +157,12 @@ describe('Workspace', () => { it('is idempotent when already ready', async () => { const fs = makeFakeFilesystem({ - _init: jest.fn().mockResolvedValue(undefined), + _init: vi.fn().mockResolvedValue(undefined), }); const ws = new Workspace({ filesystem: fs }); await ws.init(); - (fs._init as jest.Mock).mockClear(); + (fs._init as Mock).mockClear(); await ws.init(); @@ -170,7 +172,7 @@ describe('Workspace', () => { it('deduplicates concurrent init calls', async () => { let resolveInit: () => void; const fs = makeFakeFilesystem({ - _init: jest.fn( + _init: vi.fn( async () => await new Promise((r) => { resolveInit = r; @@ -194,13 +196,13 @@ describe('Workspace', () => { it('calls sandbox._destroy then filesystem._destroy', async () => { const order: string[] = []; const fs = makeFakeFilesystem({ - _destroy: jest.fn(async () => { + _destroy: vi.fn(async () => { await Promise.resolve(); order.push('fs-destroy'); }), }); const sb = makeFakeSandbox({ - _destroy: jest.fn(async () => { + _destroy: vi.fn(async () => { await Promise.resolve(); order.push('sb-destroy'); }), @@ -221,10 +223,10 @@ describe('Workspace', () => { it('transitions to error when sandbox destroy throws', async () => { const fs = makeFakeFilesystem({ - _destroy: jest.fn().mockResolvedValue(undefined), + _destroy: vi.fn().mockResolvedValue(undefined), }); const sb = makeFakeSandbox({ - _destroy: jest.fn().mockRejectedValue(new Error('sandbox boom')), + _destroy: vi.fn().mockRejectedValue(new Error('sandbox boom')), }); const ws = new Workspace({ filesystem: fs, sandbox: sb }); @@ -285,7 +287,7 @@ describe('Workspace', () => { it('returns execute_command tool when sandbox has executeCommand', () => { const sb = makeFakeSandbox({ - executeCommand: jest.fn(), + executeCommand: vi.fn(), }); const ws = new Workspace({ sandbox: sb }); diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 3a621c7114b..40e540a6525 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -1,3 +1,5 @@ +import * as aiModule from 'ai'; +import type { Mock, MockedFunction } from 'vitest'; import { z } from 'zod'; import { isLlmMessage } from '../../sdk/message'; @@ -12,20 +14,21 @@ import type { BuiltTelemetry } from '../../types/telemetry'; import { AgentRuntime } from '../agent-runtime'; import { AgentEventBus } from '../event-bus'; import { InMemoryMemory } from '../memory-store'; +import { toAiSdkTools } from '../tool-adapter'; // --------------------------------------------------------------------------- // Module mocks // --------------------------------------------------------------------------- // Mock provider packages so createModel() doesn't fail when no API key is set -jest.mock('@ai-sdk/openai', () => ({ +vi.mock('@ai-sdk/openai', () => ({ createOpenAI: () => Object.assign(() => ({ provider: 'openai', modelId: 'mock', specificationVersion: 'v3' }), { embeddingModel: () => ({ provider: 'openai', modelId: 'mock', specificationVersion: 'v2' }), }), })); -jest.mock('@ai-sdk/anthropic', () => ({ +vi.mock('@ai-sdk/anthropic', () => ({ createAnthropic: () => () => ({ provider: 'anthropic', modelId: 'mock', @@ -37,17 +40,17 @@ jest.mock('@ai-sdk/anthropic', () => ({ type AiImport = typeof import('ai'); // Mock generateText and streamText from the 'ai' package -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); return { ...actual, - embed: jest.fn(), - embedMany: jest.fn(), - generateText: jest.fn(), - streamText: jest.fn(), - tool: jest.fn((config: unknown) => config), + embed: vi.fn(), + embedMany: vi.fn(), + generateText: vi.fn(), + streamText: vi.fn(), + tool: vi.fn((config: unknown) => config), Output: { - object: jest.fn(({ schema }: { schema: unknown }) => ({ _type: 'object', schema })), + object: vi.fn(({ schema }: { schema: unknown }) => ({ _type: 'object', schema })), }, }; }); @@ -56,12 +59,11 @@ jest.mock('ai', () => { // Helpers // --------------------------------------------------------------------------- -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { embed, embedMany, generateText, streamText } = require('ai') as { - embed: jest.Mock; - embedMany: jest.Mock; - generateText: jest.Mock; - streamText: jest.Mock; +const { embed, embedMany, generateText, streamText } = aiModule as unknown as { + embed: Mock; + embedMany: Mock; + generateText: Mock; + streamText: Mock; }; /** Minimal successful generateText response. */ @@ -111,9 +113,9 @@ function makeErrorStream(error: Error) { function makeExecutionCounter() { return { - incrementMessageCount: jest.fn(), - incrementToolCallCount: jest.fn(), - incrementTokenCount: jest.fn(), + incrementMessageCount: vi.fn(), + incrementToolCallCount: vi.fn(), + incrementTokenCount: vi.fn(), }; } @@ -305,7 +307,7 @@ describe('AgentRuntime — execution counters', () => { describe('AgentRuntime.generate() — graceful error contract', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('resolves (never rejects) when the LLM call throws', async () => { @@ -432,7 +434,7 @@ describe('AgentRuntime.generate() — graceful error contract', () => { describe('AgentRuntime.stream() — graceful error contract', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('resolves (never rejects) when the LLM stream throws', async () => { @@ -574,7 +576,7 @@ describe('AgentRuntime.stream() — graceful error contract', () => { describe('AgentRuntime.resume() — graceful error contract', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('rejects with an error when the runId is not found', async () => { @@ -596,7 +598,7 @@ describe('AgentRuntime.resume() — graceful error contract', () => { describe('AgentRuntime — state transitions on error', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('starts idle, then reflects running→failed after a generate error', async () => { @@ -1440,7 +1442,7 @@ describe('AgentRuntime — concurrent tool execution', () => { describe('AgentRuntime.generate() — structured output', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('returns structuredOutput when schema is configured', async () => { @@ -1522,7 +1524,7 @@ describe('AgentRuntime.generate() — structured output', () => { describe('AgentRuntime.stream() — structured output', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('includes structuredOutput in the finish chunk when schema is configured', async () => { @@ -1636,7 +1638,7 @@ describe('AgentRuntime.stream() — structured output', () => { describe('AgentRuntime.resume() — structured output', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('returns structuredOutput after resume in generate mode', async () => { @@ -1704,16 +1706,12 @@ describe('AgentRuntime.resume() — structured output', () => { /* eslint-disable @typescript-eslint/require-await */ describe('providerOptions — tool adapter', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('forwards providerOptions to the AI SDK tool when set', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const ai = require('ai') as { tool: jest.Mock }; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const adapter = require('../tool-adapter') as { - toAiSdkTools: (tools: BuiltTool[]) => Record; - }; + const ai = aiModule as unknown as { tool: Mock }; + const adapter = { toAiSdkTools }; const builtTool: BuiltTool = { name: 'set_code', @@ -1736,12 +1734,8 @@ describe('providerOptions — tool adapter', () => { }); it('forwards arbitrary provider options (not just Anthropic)', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const ai = require('ai') as { tool: jest.Mock }; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const adapter = require('../tool-adapter') as { - toAiSdkTools: (tools: BuiltTool[]) => Record; - }; + const ai = aiModule as unknown as { tool: Mock }; + const adapter = { toAiSdkTools }; const builtTool: BuiltTool = { name: 'draw', @@ -1761,12 +1755,8 @@ describe('providerOptions — tool adapter', () => { }); it('does not pass providerOptions when not set', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const ai = require('ai') as { tool: jest.Mock }; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const adapter = require('../tool-adapter') as { - toAiSdkTools: (tools: BuiltTool[]) => Record; - }; + const ai = aiModule as unknown as { tool: Mock }; + const adapter = { toAiSdkTools }; const builtTool: BuiltTool = { name: 'search', @@ -1831,7 +1821,7 @@ describe('Tool builder — providerOptions', () => { describe('AgentRuntime — runtime input schema validation', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('surfaces a ZodError as a tool error outcome when LLM provides invalid input', async () => { @@ -1878,7 +1868,7 @@ describe('AgentRuntime — runtime input schema validation', () => { describe('AgentRuntime — runtime JSON Schema input validation', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('passes valid input through without error', async () => { @@ -1994,7 +1984,7 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => { }); it('does not invoke the handler when JSON Schema validation fails', async () => { - const handlerFn = jest.fn().mockResolvedValue({ ok: true }); + const handlerFn = vi.fn().mockResolvedValue({ ok: true }); const tool: BuiltTool = { name: 'json_tool', description: 'json tool', @@ -2032,11 +2022,11 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => { describe('AgentRuntime — Tool builder with JSON Schema input', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('passes valid input to the handler when built via Tool builder', async () => { - const handlerFn = jest.fn().mockResolvedValue({ found: true }); + const handlerFn = vi.fn().mockResolvedValue({ found: true }); const tool = new Tool('lookup') .description('Look up a record by id') @@ -2076,7 +2066,7 @@ describe('AgentRuntime — Tool builder with JSON Schema input', () => { }); it('produces a tool error when the LLM sends input that fails JSON Schema validation', async () => { - const handlerFn = jest.fn().mockResolvedValue({ found: true }); + const handlerFn = vi.fn().mockResolvedValue({ found: true }); const tool = new Tool('lookup') .description('Look up a record by id') @@ -2119,7 +2109,7 @@ describe('AgentRuntime — Tool builder with JSON Schema input', () => { }); it('validates enum and pattern constraints defined in JSON Schema', async () => { - const handlerFn = jest.fn().mockResolvedValue({ ok: true }); + const handlerFn = vi.fn().mockResolvedValue({ ok: true }); const tool = new Tool('set_status') .description('Set the status of a record') @@ -2165,7 +2155,7 @@ describe('AgentRuntime — Tool builder with JSON Schema input', () => { describe('AgentRuntime — runtime resume data schema validation', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('surfaces a ZodError as a top-level error when consumer provides invalid resume data', async () => { @@ -2203,7 +2193,7 @@ describe('AgentRuntime — runtime resume data schema validation', () => { describe('AgentRuntime — tool approval (HITL wrapper)', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('suspends when a tool has .requireApproval() set', async () => { @@ -2316,7 +2306,7 @@ describe('AgentRuntime — tool approval (HITL wrapper)', () => { describe('external abort signal', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('should cancel run when external signal fires', async () => { @@ -2359,7 +2349,7 @@ describe('external abort signal', () => { describe('provider options merging', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('should deep-merge thinking config with call-level providerOptions', async () => { @@ -2379,7 +2369,6 @@ describe('provider options merging', () => { }, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const callArgs = generateText.mock.calls[0][0] as Record; expect((callArgs.providerOptions as Record>).anthropic).toEqual( { @@ -2396,11 +2385,10 @@ describe('provider options merging', () => { describe('tool systemInstruction merging', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); function getSystemMessageText(): string { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const callArgs = generateText.mock.calls[0][0] as Record; const messages = callArgs.messages as Array>; const systemMsg = messages[0]; @@ -2504,7 +2492,7 @@ describe('tool systemInstruction merging', () => { describe('instruction providerOptions', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('should attach providerOptions to system message', async () => { @@ -2523,7 +2511,6 @@ describe('instruction providerOptions', () => { persistence: { resourceId: 'user1', threadId: 'thread1' }, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const callArgs = generateText.mock.calls[0][0] as Record; const messages = callArgs.messages as Array>; const systemMsg = messages[0]; @@ -2540,7 +2527,7 @@ describe('instruction providerOptions', () => { describe('AgentRuntime — observation log jobs', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('schedules observation after a persisted stream turn', async () => { @@ -2628,8 +2615,8 @@ describe('AgentRuntime — observation log jobs', () => { const memory = new InMemoryMemory() as InMemoryMemory & Required>; const fakeEmbedder = { specificationVersion: 'v2' } as never; - const observationLockSpy = jest.spyOn(memory, 'acquireObservationLogTaskLock'); - const episodicLockSpy = jest.spyOn(memory.episodic.taskLock!, 'acquire'); + const observationLockSpy = vi.spyOn(memory, 'acquireObservationLogTaskLock'); + const episodicLockSpy = vi.spyOn(memory.episodic.taskLock!, 'acquire'); const runtime = new AgentRuntime({ name: 'observing-agent', @@ -2701,7 +2688,7 @@ describe('AgentRuntime — observation log jobs', () => { createdAt: new Date('2026-05-20T12:00:00Z'), }, ]); - const extract = jest.fn(async () => { + const extract = vi.fn(async () => { await Promise.resolve(); return { @@ -2713,7 +2700,7 @@ describe('AgentRuntime — observation log jobs', () => { ], }; }); - jest.spyOn(memory.episodic.taskLock!, 'acquire').mockResolvedValue(null); + vi.spyOn(memory.episodic.taskLock!, 'acquire').mockResolvedValue(null); const runtime = new AgentRuntime({ name: 'observing-agent', @@ -2951,7 +2938,7 @@ describe('AgentRuntime — observation log jobs', () => { persistence: { threadId: 'shared-thread', resourceId: 'resource-2' }, }); - const generateTextMock = generateText as jest.MockedFunction< + const generateTextMock = generateText as MockedFunction< (input: { messages: Array<{ role: string; @@ -3021,7 +3008,7 @@ describe('AgentRuntime — observation log jobs', () => { persistence: { threadId: 'thread-1', resourceId: 'resource-1' }, }); - const generateTextMock = generateText as jest.MockedFunction< + const generateTextMock = generateText as MockedFunction< (input: { messages: unknown[] }) => unknown >; const [{ messages }] = generateTextMock.mock.calls[0]; @@ -3143,7 +3130,7 @@ describe('AgentRuntime — observation log jobs', () => { describe('AgentRuntime — telemetry propagation', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); const baseTelemetry: BuiltTelemetry = { @@ -3153,7 +3140,7 @@ describe('AgentRuntime — telemetry propagation', () => { recordInputs: true, recordOutputs: false, integrations: [], - tracer: { startSpan: jest.fn() }, + tracer: { startSpan: vi.fn() }, }; it('passes telemetry config into generateText as experimental_telemetry', async () => { @@ -3169,7 +3156,6 @@ describe('AgentRuntime — telemetry propagation', () => { await runtime.generate('hello'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const callArgs = generateText.mock.calls[0][0] as Record; const expTelemetry = callArgs.experimental_telemetry as Record; expect(expTelemetry).toBeDefined(); @@ -3199,7 +3185,6 @@ describe('AgentRuntime — telemetry propagation', () => { runtime.setTelemetry(updatedTelemetry); await runtime.generate('hello'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const callArgs = generateText.mock.calls[0][0] as Record; const expTelemetry = callArgs.experimental_telemetry as Record; expect(expTelemetry.functionId).toBe('updated-agent'); @@ -3209,12 +3194,12 @@ describe('AgentRuntime — telemetry propagation', () => { it('wraps generate calls in a telemetry root span when the tracer supports active spans', async () => { generateText.mockResolvedValue(makeGenerateSuccess()); const span = { - end: jest.fn(), - recordException: jest.fn(), - setStatus: jest.fn(), + end: vi.fn(), + recordException: vi.fn(), + setStatus: vi.fn(), }; const tracer = { - startActiveSpan: jest.fn(async (_name: string, _options: unknown, fn: unknown) => { + startActiveSpan: vi.fn(async (_name: string, _options: unknown, fn: unknown) => { if (typeof fn !== 'function') { throw new Error('Expected span callback'); } @@ -3254,7 +3239,7 @@ describe('AgentRuntime — telemetry propagation', () => { it('can suppress the generic runtime root span while keeping native telemetry enabled', async () => { generateText.mockResolvedValue(makeGenerateSuccess()); const tracer = { - startActiveSpan: jest.fn(), + startActiveSpan: vi.fn(), }; const telemetry: BuiltTelemetry = { ...baseTelemetry, @@ -3273,7 +3258,7 @@ describe('AgentRuntime — telemetry propagation', () => { await runtime.generate('hello'); expect(tracer.startActiveSpan).not.toHaveBeenCalled(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const callArgs = generateText.mock.calls[0][0] as Record; expect(callArgs.experimental_telemetry).toEqual( expect.objectContaining({ @@ -3287,12 +3272,12 @@ describe('AgentRuntime — telemetry propagation', () => { it('adds a LangSmith tool catalog to telemetry root spans', async () => { generateText.mockResolvedValue(makeGenerateSuccess()); const span = { - end: jest.fn(), - recordException: jest.fn(), - setStatus: jest.fn(), + end: vi.fn(), + recordException: vi.fn(), + setStatus: vi.fn(), }; const tracer = { - startActiveSpan: jest.fn(async (_name: string, _options: unknown, fn: unknown) => { + startActiveSpan: vi.fn(async (_name: string, _options: unknown, fn: unknown) => { if (typeof fn !== 'function') { throw new Error('Expected span callback'); } @@ -3358,7 +3343,6 @@ describe('AgentRuntime — telemetry propagation', () => { const { stream } = await runtime.stream('hello'); await collectChunks(stream); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const callArgs = streamText.mock.calls[0][0] as Record; const expTelemetry = callArgs.experimental_telemetry as Record; expect(expTelemetry).toBeDefined(); @@ -3383,7 +3367,6 @@ describe('AgentRuntime — telemetry propagation', () => { telemetry: baseTelemetry, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const callArgs = generateText.mock.calls[0][0] as Record; const expTelemetry = callArgs.experimental_telemetry as Record; expect(expTelemetry).toBeDefined(); @@ -3431,22 +3414,22 @@ describe('AgentRuntime — telemetry propagation', () => { const spans: Array<{ name: string; span: { - end: jest.Mock; - recordException: jest.Mock; - setAttributes: jest.Mock; - setStatus: jest.Mock; + end: Mock; + recordException: Mock; + setAttributes: Mock; + setStatus: Mock; }; }> = []; const tracer = { - startActiveSpan: jest.fn(async (name: string, _options: unknown, fn: unknown) => { + startActiveSpan: vi.fn(async (name: string, _options: unknown, fn: unknown) => { if (typeof fn !== 'function') { throw new Error('Expected span callback'); } const span = { - end: jest.fn(), - recordException: jest.fn(), - setAttributes: jest.fn(), - setStatus: jest.fn(), + end: vi.fn(), + recordException: vi.fn(), + setAttributes: vi.fn(), + setStatus: vi.fn(), }; spans.push({ name, span }); const spanFn = fn as (spanValue: typeof span) => Promise; @@ -3552,7 +3535,6 @@ describe('AgentRuntime — telemetry propagation', () => { await runtime.generate('hello'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const callArgs = generateText.mock.calls[0][0] as Record; expect(callArgs.experimental_telemetry).toBeUndefined(); }); diff --git a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts index 179396b2a68..08e1275afb7 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts @@ -19,10 +19,12 @@ type GenerateObjectCall = { type GenerateObjectResult = { object: unknown; usage?: { totalTokens?: number } }; -const mockGenerateObject = jest.fn, [GenerateObjectCall]>(); +const { mockGenerateObject } = vi.hoisted(() => ({ + mockGenerateObject: vi.fn<(...args: [GenerateObjectCall]) => Promise>(), +})); -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); return { ...actual, generateObject: async (call: GenerateObjectCall): Promise => @@ -30,7 +32,7 @@ jest.mock('ai', () => { }; }); -const fakeModel = { doGenerate: jest.fn() } as unknown as ModelConfig; +const fakeModel = { doGenerate: vi.fn() } as unknown as ModelConfig; describe('episodic memory defaults', () => { beforeEach(() => { @@ -175,9 +177,9 @@ describe('episodic memory defaults', () => { it('counts extraction and reflection generation tokens when usage is available', async () => { const counter = { - incrementMessageCount: jest.fn(), - incrementToolCallCount: jest.fn(), - incrementTokenCount: jest.fn(), + incrementMessageCount: vi.fn(), + incrementToolCallCount: vi.fn(), + incrementTokenCount: vi.fn(), }; mockGenerateObject.mockImplementationOnce(async ({ schema }) => { diff --git a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory.test.ts b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory.test.ts index 7ca40fe4b46..5f8ad3b48bb 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory.test.ts @@ -15,13 +15,13 @@ import { } from '../episodic-memory'; import { InMemoryMemory } from '../memory-store'; -jest.mock('ai', () => ({ - embed: jest.fn(), - embedMany: jest.fn(), +vi.mock('ai', () => ({ + embed: vi.fn(), + embedMany: vi.fn(), })); -const mockedEmbed = jest.mocked(embed); -const mockedEmbedMany = jest.mocked(embedMany); +const mockedEmbed = vi.mocked(embed); +const mockedEmbedMany = vi.mocked(embedMany); const fakeEmbedder = { specificationVersion: 'v2' } as never; function entry(overrides: Partial = {}): EpisodicMemoryEntry { @@ -219,9 +219,9 @@ describe('createRecallMemoryTool', () => { it('counts recall query embedding tokens when usage is available', async () => { mockedEmbed.mockResolvedValue({ embedding: [1, 0], usage: { tokens: 7 } } as never); const counter = { - incrementMessageCount: jest.fn(), - incrementToolCallCount: jest.fn(), - incrementTokenCount: jest.fn(), + incrementMessageCount: vi.fn(), + incrementToolCallCount: vi.fn(), + incrementTokenCount: vi.fn(), }; const memory = new InMemoryMemory(); const tool = createRecallMemoryTool({ @@ -306,7 +306,7 @@ describe('InMemoryMemory episodic source cleanup', () => { describe('runEpisodicMemoryIndexer', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); mockedEmbedMany.mockResolvedValue({ embeddings: [[1, 0]], usage: { tokens: 1 } } as never); mockedEmbed.mockResolvedValue({ embedding: [1, 0], usage: { tokens: 1 } } as never); }); @@ -376,7 +376,7 @@ describe('runEpisodicMemoryIndexer', () => { }, ]); const sourceError = new Error('entry/source write failed'); - jest.spyOn(memory.episodic, 'saveEntryWithSources').mockRejectedValueOnce(sourceError); + vi.spyOn(memory.episodic, 'saveEntryWithSources').mockRejectedValueOnce(sourceError); const extract: EpisodicMemoryExtractFn = async () => await Promise.resolve({ entries: [ @@ -411,9 +411,9 @@ describe('runEpisodicMemoryIndexer', () => { it('counts episodic entry embedding tokens when usage is available', async () => { mockedEmbedMany.mockResolvedValue({ embeddings: [[1, 0]], usage: { tokens: 23 } } as never); const counter = { - incrementMessageCount: jest.fn(), - incrementToolCallCount: jest.fn(), - incrementTokenCount: jest.fn(), + incrementMessageCount: vi.fn(), + incrementToolCallCount: vi.fn(), + incrementTokenCount: vi.fn(), }; const memory = new InMemoryMemory(); const [observation] = await memory.appendObservationLogEntries([ @@ -738,9 +738,9 @@ describe('runEpisodicMemoryIndexer', () => { usage: { tokens: 2 }, } as never); const counter = { - incrementMessageCount: jest.fn(), - incrementToolCallCount: jest.fn(), - incrementTokenCount: jest.fn(), + incrementMessageCount: vi.fn(), + incrementToolCallCount: vi.fn(), + incrementTokenCount: vi.fn(), }; await runEpisodicMemoryIndexer({ diff --git a/packages/@n8n/agents/src/runtime/__tests__/mcp-connection.test.ts b/packages/@n8n/agents/src/runtime/__tests__/mcp-connection.test.ts index 7a0be6f251c..cf1009a860c 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/mcp-connection.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/mcp-connection.test.ts @@ -1,18 +1,18 @@ import { McpConnection } from '../mcp-connection'; -const sseCtor = jest.fn(); -const streamableHttpCtor = jest.fn(); -const stdioCtor = jest.fn(); +const sseCtor = vi.fn(); +const streamableHttpCtor = vi.fn(); +const stdioCtor = vi.fn(); -const clientConnect = jest.fn().mockResolvedValue(undefined); -const clientListTools = jest.fn().mockResolvedValue({ +const clientConnect = vi.fn().mockResolvedValue(undefined); +const clientListTools = vi.fn().mockResolvedValue({ tools: [ { name: 'echo', description: '', inputSchema: { type: 'object' } }, { name: 'add', description: '', inputSchema: { type: 'object' } }, { name: 'subtract', description: '', inputSchema: { type: 'object' } }, ], }); -const clientClose = jest.fn().mockResolvedValue(undefined); +const clientClose = vi.fn().mockResolvedValue(undefined); class FakeClient { connect = clientConnect; @@ -20,32 +20,34 @@ class FakeClient { close = clientClose; } -jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ - Client: jest.fn(() => new FakeClient()), +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: vi.fn(function () { + return new FakeClient(); + }), })); -jest.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({ - SSEClientTransport: jest.fn((url: URL, options: unknown) => { +vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({ + SSEClientTransport: vi.fn(function (url: URL, options: unknown) { sseCtor(url, options); return { type: 'sse', url, options }; }), })); -jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ - StdioClientTransport: jest.fn((options: unknown) => { +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: vi.fn(function (options: unknown) { stdioCtor(options); return { type: 'stdio', options }; }), })); -jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ - StreamableHTTPClientTransport: jest.fn((url: URL, options: unknown) => { +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: vi.fn(function (url: URL, options: unknown) { streamableHttpCtor(url, options); return { type: 'streamableHttp', url, options }; }), })); -jest.mock('@modelcontextprotocol/sdk/types.js', () => ({ +vi.mock('@modelcontextprotocol/sdk/types.js', () => ({ CallToolResultSchema: {}, })); @@ -63,7 +65,7 @@ describe('McpConnection — custom fetch forwarding', () => { }); it('forwards `fetch` to StreamableHTTPClientTransport when provided', async () => { - const customFetch = jest.fn(); + const customFetch = vi.fn(); const conn = new McpConnection({ name: 's1', url: 'https://example.test/mcp', @@ -79,7 +81,7 @@ describe('McpConnection — custom fetch forwarding', () => { }); it('forwards `fetch` to SSEClientTransport and to its eventSourceInit when provided', async () => { - const customFetch = jest.fn(); + const customFetch = vi.fn(); const conn = new McpConnection({ name: 's2', url: 'https://example.test/mcp', diff --git a/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts b/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts index ef6144fcce9..9edca3cc33f 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts @@ -9,9 +9,9 @@ type ProviderOpts = { headers?: Record; }; -// All providers are mocked via jest.mock so require() inside the registry entries +// All providers are mocked via vi.mock so require() inside the registry entries // returns these stubs instead of the real packages. -jest.mock('@ai-sdk/anthropic', () => ({ +vi.mock('@ai-sdk/anthropic', () => ({ createAnthropic: (opts?: ProviderOpts) => (model: string) => ({ provider: 'anthropic', modelId: model, @@ -23,7 +23,7 @@ jest.mock('@ai-sdk/anthropic', () => ({ }), })); -jest.mock('@ai-sdk/openai', () => ({ +vi.mock('@ai-sdk/openai', () => ({ createOpenAI: (opts?: ProviderOpts) => Object.assign( (model: string) => ({ @@ -47,7 +47,7 @@ jest.mock('@ai-sdk/openai', () => ({ ), })); -jest.mock('@ai-sdk/google', () => ({ +vi.mock('@ai-sdk/google', () => ({ createGoogleGenerativeAI: (opts?: ProviderOpts) => (model: string) => ({ provider: 'google', modelId: model, @@ -57,7 +57,7 @@ jest.mock('@ai-sdk/google', () => ({ }), })); -jest.mock('@ai-sdk/xai', () => ({ +vi.mock('@ai-sdk/xai', () => ({ createXai: (opts?: ProviderOpts) => (model: string) => ({ provider: 'xai', modelId: model, @@ -67,7 +67,7 @@ jest.mock('@ai-sdk/xai', () => ({ }), })); -jest.mock('@ai-sdk/groq', () => ({ +vi.mock('@ai-sdk/groq', () => ({ createGroq: (opts?: ProviderOpts) => (model: string) => ({ provider: 'groq', modelId: model, @@ -77,7 +77,7 @@ jest.mock('@ai-sdk/groq', () => ({ }), })); -jest.mock('@ai-sdk/deepseek', () => ({ +vi.mock('@ai-sdk/deepseek', () => ({ createDeepSeek: (opts?: ProviderOpts) => (model: string) => ({ provider: 'deepseek', modelId: model, @@ -87,7 +87,7 @@ jest.mock('@ai-sdk/deepseek', () => ({ }), })); -jest.mock('@ai-sdk/cohere', () => ({ +vi.mock('@ai-sdk/cohere', () => ({ createCohere: (opts?: ProviderOpts) => (model: string) => ({ provider: 'cohere', modelId: model, @@ -97,7 +97,7 @@ jest.mock('@ai-sdk/cohere', () => ({ }), })); -jest.mock('@ai-sdk/mistral', () => ({ +vi.mock('@ai-sdk/mistral', () => ({ createMistral: (opts?: ProviderOpts) => (model: string) => ({ provider: 'mistral', modelId: model, @@ -107,7 +107,7 @@ jest.mock('@ai-sdk/mistral', () => ({ }), })); -jest.mock('@ai-sdk/gateway', () => ({ +vi.mock('@ai-sdk/gateway', () => ({ createGateway: (opts?: ProviderOpts) => (model: string) => ({ provider: 'vercel', modelId: model, @@ -118,7 +118,7 @@ jest.mock('@ai-sdk/gateway', () => ({ }), })); -jest.mock('@ai-sdk/azure', () => ({ +vi.mock('@ai-sdk/azure', () => ({ createAzure: (opts?: { apiKey?: string; resourceName?: string; apiVersion?: string; baseURL?: string }) => (model: string) => ({ @@ -131,7 +131,7 @@ jest.mock('@ai-sdk/azure', () => ({ }), })); -jest.mock('@openrouter/ai-sdk-provider', () => ({ +vi.mock('@openrouter/ai-sdk-provider', () => ({ createOpenRouter: (opts?: ProviderOpts) => (model: string) => ({ provider: 'openrouter', modelId: model, @@ -142,7 +142,7 @@ jest.mock('@openrouter/ai-sdk-provider', () => ({ }), })); -jest.mock('@ai-sdk/amazon-bedrock', () => ({ +vi.mock('@ai-sdk/amazon-bedrock', () => ({ createAmazonBedrock: (opts?: { region?: string; @@ -160,8 +160,8 @@ jest.mock('@ai-sdk/amazon-bedrock', () => ({ }), })); -const mockProxyAgent = jest.fn(); -jest.mock('undici', () => ({ +const { mockProxyAgent } = vi.hoisted(() => ({ mockProxyAgent: vi.fn() })); +vi.mock('undici', () => ({ ProxyAgent: mockProxyAgent, })); @@ -197,8 +197,8 @@ describe('createModel', () => { it('should pass through a prebuilt LanguageModel', () => { const prebuilt = { - doGenerate: jest.fn(), - doStream: jest.fn(), + doGenerate: vi.fn(), + doStream: vi.fn(), specificationVersion: 'v2' as const, modelId: 'custom-model', provider: 'custom', diff --git a/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts b/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts index 301bd8a591f..edf3bf4e5c6 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts @@ -18,10 +18,12 @@ import { type GenerateTextCall = Record; type GenerateTextResult = { text: string; usage?: { totalTokens?: number } }; -const mockGenerateText = jest.fn, [GenerateTextCall]>(); +const { mockGenerateText } = vi.hoisted(() => ({ + mockGenerateText: vi.fn<(...args: [GenerateTextCall]) => Promise>(), +})); -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); return { ...actual, generateText: async (call: GenerateTextCall): Promise => @@ -84,9 +86,9 @@ describe('observation-log observer defaults', () => { usage: { totalTokens: 17 }, }); const counter = { - incrementMessageCount: jest.fn(), - incrementToolCallCount: jest.fn(), - incrementTokenCount: jest.fn(), + incrementMessageCount: vi.fn(), + incrementToolCallCount: vi.fn(), + incrementTokenCount: vi.fn(), }; const result = await createObservationLogObserveFn('openai/gpt-4o-mini')({ @@ -249,7 +251,7 @@ describe('runObservationLogObserver', () => { messages: [message('m1', 'user', 'short turn', new Date(2026, 4, 12, 14, 30))], }); - const observe = jest.fn().mockResolvedValue('* CRITICAL (14:30) User said something durable.'); + const observe = vi.fn().mockResolvedValue('* CRITICAL (14:30) User said something durable.'); const result = await runObservationLogObserver({ memory: store, diff --git a/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts b/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts index a7a5deacb51..34bd36649d3 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts @@ -18,10 +18,12 @@ import { type GenerateTextCall = Record; type GenerateTextResult = { text: string; usage?: { totalTokens?: number } }; -const mockGenerateText = jest.fn, [GenerateTextCall]>(); +const { mockGenerateText } = vi.hoisted(() => ({ + mockGenerateText: vi.fn<(...args: [GenerateTextCall]) => Promise>(), +})); -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); return { ...actual, generateText: async (call: GenerateTextCall): Promise => @@ -80,9 +82,9 @@ describe('observation-log reflector defaults', () => { usage: { totalTokens: 19 }, }); const counter = { - incrementMessageCount: jest.fn(), - incrementToolCallCount: jest.fn(), - incrementTokenCount: jest.fn(), + incrementMessageCount: vi.fn(), + incrementToolCallCount: vi.fn(), + incrementTokenCount: vi.fn(), }; const result = await createObservationLogReflectFn('openai/gpt-4o-mini')({ @@ -268,7 +270,7 @@ describe('runObservationLogReflector', () => { tokenCount: 2, }, ]); - const reflect = jest.fn().mockResolvedValue('{"drop":[],"merge":[]}'); + const reflect = vi.fn().mockResolvedValue('{"drop":[],"merge":[]}'); const result = await runObservationLogReflector({ memory: store, diff --git a/packages/@n8n/agents/src/runtime/__tests__/scoped-memory-task-runner.test.ts b/packages/@n8n/agents/src/runtime/__tests__/scoped-memory-task-runner.test.ts index fac91b0dc24..41719c8bc0b 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/scoped-memory-task-runner.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/scoped-memory-task-runner.test.ts @@ -18,7 +18,7 @@ function lockStore( ): BuiltObservationLogTaskLockStore { return { acquireObservationLogTaskLock: acquire, - releaseObservationLogTaskLock: jest.fn().mockResolvedValue(undefined), + releaseObservationLogTaskLock: vi.fn().mockResolvedValue(undefined), }; } @@ -71,9 +71,9 @@ describe('ScopedMemoryTaskRunner', () => { }); it('skips a task when the store lock is already held', async () => { - const acquire = jest.fn().mockResolvedValue(null); + const acquire = vi.fn().mockResolvedValue(null); const runner = new ScopedMemoryTaskRunner({ lockStore: lockStore(acquire) }); - const task = jest.fn().mockResolvedValue(undefined); + const task = vi.fn().mockResolvedValue(undefined); const handle = runner.schedule({ taskKind: 'observer', observationScopeId: 'thread-1' }, task); @@ -135,7 +135,7 @@ describe('ScopedMemoryTaskRunner', () => { it('captures onEvent failures without failing the task lifecycle', async () => { const eventError = new Error('event sink failed'); - const task = jest.fn(async () => await Promise.resolve('done')); + const task = vi.fn(async () => await Promise.resolve('done')); const runner = new ScopedMemoryTaskRunner({ onEvent: () => { throw eventError; @@ -167,9 +167,10 @@ describe('ScopedMemoryTaskRunner', () => { }); it('acquires and releases a store lock around the task', async () => { - const acquire = jest.fn< - ReturnType, - Parameters + const acquire = vi.fn< + ( + ...args: Parameters + ) => ReturnType >( async (observationScopeId, taskKind, opts) => await Promise.resolve(lockHandle(observationScopeId, taskKind, opts.holderId)), diff --git a/packages/@n8n/agents/src/runtime/__tests__/title-generation.test.ts b/packages/@n8n/agents/src/runtime/__tests__/title-generation.test.ts index 36dabfca3c7..8af64eb41f2 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/title-generation.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/title-generation.test.ts @@ -11,10 +11,12 @@ type GenerateTextCall = { type GenerateTextResult = { text: string; usage?: { totalTokens?: number } }; -const mockGenerateText = jest.fn, [GenerateTextCall]>(); +const { mockGenerateText } = vi.hoisted(() => ({ + mockGenerateText: vi.fn<(...args: [GenerateTextCall]) => Promise>(), +})); -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); return { ...actual, generateText: async (call: GenerateTextCall): Promise => @@ -156,9 +158,9 @@ describe('generateTitleFromMessage', () => { it('counts title generation tokens when usage is available', async () => { mockGenerateText.mockResolvedValue({ text: 'Berlin rain alert', usage: { totalTokens: 9 } }); const counter = { - incrementMessageCount: jest.fn(), - incrementToolCallCount: jest.fn(), - incrementTokenCount: jest.fn(), + incrementMessageCount: vi.fn(), + incrementToolCallCount: vi.fn(), + incrementTokenCount: vi.fn(), }; await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow', { diff --git a/packages/@n8n/agents/src/runtime/__tests__/tool-adapter.test.ts b/packages/@n8n/agents/src/runtime/__tests__/tool-adapter.test.ts index 2c84f93172c..908c7cdc7e5 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/tool-adapter.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/tool-adapter.test.ts @@ -11,13 +11,15 @@ import { toAiSdkTools } from '../tool-adapter'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports type AiImport = typeof import('ai'); -const jsonSchemaMock = jest.fn((schema: JSONSchema7) => ({ __jsonSchema: schema })); +const { jsonSchemaMock } = vi.hoisted(() => ({ + jsonSchemaMock: vi.fn((schema: JSONSchema7) => ({ __jsonSchema: schema })), +})); -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); return { ...actual, - tool: jest.fn((config: unknown) => config), + tool: vi.fn((config: unknown) => config), jsonSchema: (schema: JSONSchema7) => jsonSchemaMock(schema), }; }); diff --git a/packages/@n8n/agents/src/sdk/__tests__/agent-telemetry.test.ts b/packages/@n8n/agents/src/sdk/__tests__/agent-telemetry.test.ts index 414e1488432..f72b5b985b0 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/agent-telemetry.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/agent-telemetry.test.ts @@ -1,25 +1,27 @@ +import * as aiModule from 'ai'; +import type { Mock } from 'vitest'; + import type { BuiltTelemetry } from '../../types'; import { Agent } from '../agent'; // Mock provider packages so createModel() doesn't fail when no API key is set. -jest.mock('@ai-sdk/openai', () => ({ +vi.mock('@ai-sdk/openai', () => ({ createOpenAI: () => () => ({ provider: 'openai', modelId: 'mock', specificationVersion: 'v3' }), })); // eslint-disable-next-line @typescript-eslint/consistent-type-imports type AiImport = typeof import('ai'); -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); return { ...actual, - generateText: jest.fn(), + generateText: vi.fn(), }; }); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { generateText } = require('ai') as { - generateText: jest.Mock; +const { generateText } = aiModule as unknown as { + generateText: Mock; }; function makeGenerateSuccess(text = 'OK') { @@ -46,13 +48,13 @@ function makeTelemetry(functionId: string): BuiltTelemetry { recordInputs: true, recordOutputs: true, integrations: [], - tracer: { startSpan: jest.fn() }, + tracer: { startSpan: vi.fn() }, }; } describe('Agent telemetry', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('updates telemetry on an already-built runtime', async () => { @@ -66,9 +68,8 @@ describe('Agent telemetry', () => { agent.telemetry(makeTelemetry('updated-agent')); await agent.generate('second'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const firstCall = generateText.mock.calls[0][0] as Record; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const secondCall = generateText.mock.calls[1][0] as Record; const firstTelemetry = firstCall.experimental_telemetry as Record; const secondTelemetry = secondCall.experimental_telemetry as Record; diff --git a/packages/@n8n/agents/src/sdk/__tests__/memory-builder-episodic.test.ts b/packages/@n8n/agents/src/sdk/__tests__/memory-builder-episodic.test.ts index caad92dd19c..2ab279372f7 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/memory-builder-episodic.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/memory-builder-episodic.test.ts @@ -19,7 +19,7 @@ type EmbeddingProviderOpts = { baseURL?: string; }; -jest.mock('@ai-sdk/openai', () => ({ +vi.mock('@ai-sdk/openai', () => ({ createOpenAI: (opts?: EmbeddingProviderOpts) => Object.assign( (model: string) => ({ @@ -43,12 +43,12 @@ jest.mock('@ai-sdk/openai', () => ({ describe('Memory builder — episodic memory', () => { const minimalBackend = { - getThread: jest.fn().mockResolvedValue(null), - saveThread: jest.fn().mockResolvedValue({}), - deleteThread: jest.fn().mockResolvedValue(undefined), - getMessages: jest.fn().mockResolvedValue([]), - saveMessages: jest.fn().mockResolvedValue(undefined), - deleteMessages: jest.fn().mockResolvedValue(undefined), + getThread: vi.fn().mockResolvedValue(null), + saveThread: vi.fn().mockResolvedValue({}), + deleteThread: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockResolvedValue([]), + saveMessages: vi.fn().mockResolvedValue(undefined), + deleteMessages: vi.fn().mockResolvedValue(undefined), describe: () => ({ name: 'minimal', constructorName: 'MinimalMemory', diff --git a/packages/@n8n/agents/src/sdk/__tests__/memory-builder-observational.test.ts b/packages/@n8n/agents/src/sdk/__tests__/memory-builder-observational.test.ts index 4b9fb9ac5c1..778ce81e295 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/memory-builder-observational.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/memory-builder-observational.test.ts @@ -160,12 +160,12 @@ describe('Memory builder — observation log memory', () => { it('rejects backends that do not implement the observation-log store', () => { const minimalBackend = { - getThread: jest.fn().mockResolvedValue(null), - saveThread: jest.fn().mockResolvedValue({}), - deleteThread: jest.fn().mockResolvedValue(undefined), - getMessages: jest.fn().mockResolvedValue([]), - saveMessages: jest.fn().mockResolvedValue(undefined), - deleteMessages: jest.fn().mockResolvedValue(undefined), + getThread: vi.fn().mockResolvedValue(null), + saveThread: vi.fn().mockResolvedValue({}), + deleteThread: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockResolvedValue([]), + saveMessages: vi.fn().mockResolvedValue(undefined), + deleteMessages: vi.fn().mockResolvedValue(undefined), describe: () => ({ name: 'minimal', constructorName: 'MinimalMemory', diff --git a/packages/@n8n/agents/src/sdk/__tests__/telemetry.test.ts b/packages/@n8n/agents/src/sdk/__tests__/telemetry.test.ts index 01f177dac61..005f698b80a 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/telemetry.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/telemetry.test.ts @@ -35,23 +35,20 @@ describe('Telemetry builder', () => { }); it('accepts a pre-built tracer', async () => { - const fakeTracer = { startSpan: jest.fn() }; + const fakeTracer = { startSpan: vi.fn() }; const built = await new Telemetry().tracer(fakeTracer).build(); expect(built.tracer).toBe(fakeTracer); }); it('throws when both .tracer() and .otlpEndpoint() are set', async () => { await expect( - new Telemetry() - .tracer({ startSpan: jest.fn() }) - .otlpEndpoint('http://localhost:4318') - .build(), + new Telemetry().tracer({ startSpan: vi.fn() }).otlpEndpoint('http://localhost:4318').build(), ).rejects.toThrow('Cannot set both .tracer() and .otlpEndpoint()'); }); it('collects multiple integrations', async () => { - const int1: TelemetryIntegration = { onStart: jest.fn() }; - const int2: TelemetryIntegration = { onFinish: jest.fn() }; + const int1: TelemetryIntegration = { onStart: vi.fn() }; + const int2: TelemetryIntegration = { onFinish: vi.fn() }; const built = await new Telemetry().integration(int1).integration(int2).build(); expect(built.integrations).toHaveLength(2); }); @@ -89,7 +86,7 @@ describe('Telemetry — redaction wrapping', () => { }); it('does not wrap integrations when .redact() is not set', async () => { - const integration: TelemetryIntegration = { onStart: jest.fn() }; + const integration: TelemetryIntegration = { onStart: vi.fn() }; const built = await new Telemetry().integration(integration).build(); // The integration should be a copy (not the same reference due to spread) but functionally identical expect(built.integrations[0].onStart).toBe(integration.onStart); @@ -154,12 +151,12 @@ describe('Telemetry — redaction wrapping', () => { describe('Telemetry.shutdown()', () => { it('calls provider.shutdown() when provider exists', async () => { - const shutdownMock = jest.fn().mockResolvedValue(undefined); + const shutdownMock = vi.fn().mockResolvedValue(undefined); const built = await new Telemetry().build(); // Manually inject a mock provider const withProvider = { ...built, - provider: { forceFlush: jest.fn(), shutdown: shutdownMock }, + provider: { forceFlush: vi.fn(), shutdown: shutdownMock }, }; await Telemetry.shutdown(withProvider); expect(shutdownMock).toHaveBeenCalled(); @@ -174,11 +171,11 @@ describe('Telemetry.shutdown()', () => { describe('Telemetry.forceFlush()', () => { it('calls provider.forceFlush() when provider exists', async () => { - const forceFlushMock = jest.fn().mockResolvedValue(undefined); + const forceFlushMock = vi.fn().mockResolvedValue(undefined); const built = await new Telemetry().build(); const withProvider = { ...built, - provider: { forceFlush: forceFlushMock, shutdown: jest.fn() }, + provider: { forceFlush: forceFlushMock, shutdown: vi.fn() }, }; await Telemetry.forceFlush(withProvider); @@ -191,8 +188,8 @@ describe('Telemetry.forceFlush()', () => { const withProvider = { ...built, provider: { - forceFlush: jest.fn().mockRejectedValue(new Error('flush failed')), - shutdown: jest.fn(), + forceFlush: vi.fn().mockRejectedValue(new Error('flush failed')), + shutdown: vi.fn(), }, }; diff --git a/packages/@n8n/agents/src/sdk/__tests__/tool.test.ts b/packages/@n8n/agents/src/sdk/__tests__/tool.test.ts index 7626c09bf65..86c5e5a3818 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/tool.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/tool.test.ts @@ -1,3 +1,4 @@ +import type { Mock } from 'vitest'; import { z } from 'zod'; import type { BuiltTelemetry, BuiltTool, InterruptibleToolContext, ToolContext } from '../../types'; @@ -19,8 +20,8 @@ function makeBuiltTool(overrides: Partial = {}): BuiltTool { }; } -function makeCtx(resumeData?: unknown): { ctx: InterruptibleToolContext; suspendMock: jest.Mock } { - const suspendMock = jest.fn().mockImplementation(async (payload: unknown) => { +function makeCtx(resumeData?: unknown): { ctx: InterruptibleToolContext; suspendMock: Mock } { + const suspendMock = vi.fn().mockImplementation(async (payload: unknown) => { return await Promise.resolve({ __suspended: true, payload }); }); const ctx: InterruptibleToolContext = { @@ -279,7 +280,7 @@ describe('wrapToolForApproval — telemetry propagation', () => { recordInputs: true, recordOutputs: true, integrations: [], - tracer: { startSpan: jest.fn() }, + tracer: { startSpan: vi.fn() }, }; it('forwards parentTelemetry to the original handler when approval is not needed', async () => { diff --git a/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts index c4c08259aa5..14cd680b157 100644 --- a/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts @@ -156,11 +156,9 @@ description: Has no instructions. }); it('uses locale-independent ordering for registry hashes', () => { - const localeCompareSpy = jest - .spyOn(String.prototype, 'localeCompare') - .mockImplementation(() => { - throw new Error('localeCompare must not be used for registry ordering'); - }); + const localeCompareSpy = vi.spyOn(String.prototype, 'localeCompare').mockImplementation(() => { + throw new Error('localeCompare must not be used for registry ordering'); + }); try { expect(() => @@ -416,7 +414,7 @@ Use the workflow SDK.`, instructions: 'Full private skill body: Extract decisions.', }, ]); - const prepare = jest.fn(async () => { + const prepare = vi.fn(async () => { await Promise.resolve(); source.registry = { ...source.registry, @@ -460,7 +458,7 @@ Use the workflow SDK.`, instructions: 'Extract decisions.', }, ]); - const prepare = jest.fn(async () => { + const prepare = vi.fn(async () => { await Promise.resolve(); source.registry = { ...source.registry, @@ -542,7 +540,7 @@ Use the workflow SDK.`, }, }, ]); - const loadFile = jest.fn( + const loadFile = vi.fn( async (_skillId: string, filePath: string) => await Promise.resolve({ skillId: 'summarize_notes', diff --git a/packages/@n8n/agents/tsconfig.json b/packages/@n8n/agents/tsconfig.json index b8a2789ee33..f989681db5e 100644 --- a/packages/@n8n/agents/tsconfig.json +++ b/packages/@n8n/agents/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@n8n/typescript-config/tsconfig.common.json", "compilerOptions": { - "types": ["node", "jest"], + "types": ["node", "vitest/globals"], "tsBuildInfoFile": "dist/typecheck.tsbuildinfo" }, "include": ["src/**/*.ts"], diff --git a/packages/@n8n/agents/vite.config.ts b/packages/@n8n/agents/vite.config.ts new file mode 100644 index 00000000000..237dc8bb2db --- /dev/null +++ b/packages/@n8n/agents/vite.config.ts @@ -0,0 +1,64 @@ +import { createVitestConfig } from '@n8n/vitest-config/node'; +import type { Plugin } from 'vite'; +import { mergeConfig } from 'vite'; +import { configDefaults } from 'vitest/config'; + +/** + * Source modules lazy-load their heavy deps via `require()` (intentional runtime + * lazy-loading — see `lazy-ai.ts` and `model-factory.ts`). Under Vitest, source is + * ESM and gets a Node `createRequire`, which `vi.mock()` does not intercept, so the + * AI SDK / provider mocks in the unit tests never apply. + * + * This plugin rewrites those `require('spec')` calls to eager static imports at + * transform time (test runtime only — the files on disk are untouched), which Vite + * resolves and `vi.mock` can intercept. Dynamic `require(expr)` calls are routed + * through the same eager-import map. The integration config does not use this plugin + * because those tests deliberately exercise the real provider packages. + */ +const REWRITE_REQUIRE_TARGETS = [ + '/src/runtime/lazy-ai.ts', + '/src/runtime/model-factory.ts', + '/src/utils/parse.ts', +]; + +function rewriteSourceRequire(): Plugin { + return { + name: 'rewrite-source-require', + enforce: 'pre', + transform(code, id) { + const normalized = id.replace(/\\/g, '/'); + if (!REWRITE_REQUIRE_TARGETS.some((target) => normalized.endsWith(target))) return null; + + const literalRequire = /require\((['"])([^'"]+)\1\)/g; + const specs: string[] = []; + for (const match of code.matchAll(literalRequire)) { + if (!specs.includes(match[2])) specs.push(match[2]); + } + if (!specs.length) return null; + + const imports = specs.map((spec, i) => `import * as __req_${i} from '${spec}';`).join('\n'); + const mapEntries = specs.map((spec, i) => `${JSON.stringify(spec)}: __req_${i}`).join(', '); + const rewritten = code + .replace(literalRequire, (_full, _quote, spec) => `__requireMap[${JSON.stringify(spec)}]`) + .replace(/require\(([^'")][^)]*)\)/g, (_full, expr) => `__requireMap[${expr}]`); + + return { + code: `${imports}\nconst __requireMap = { ${mapEntries} };\n${rewritten}`, + map: null, + }; + }, + }; +} + +export default mergeConfig( + createVitestConfig({ + // The n8n root jest.config sets `restoreMocks: true`, and test files silently rely on + // it — omit this and mocks bleed between tests. + restoreMocks: true, + // Integration tests run via vitest.integration.config.mjs (real providers, long timeouts). + exclude: [...configDefaults.exclude, '**/__tests__/integration/**'], + }), + { + plugins: [rewriteSourceRequire()], + }, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9e14ea4296..0ebb4f49c9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -771,9 +771,21 @@ importers: '@n8n/typescript-config': specifier: workspace:* version: link:../typescript-config + '@n8n/vitest-config': + specifier: workspace:* + version: link:../vitest-config '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(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: + specifier: 'catalog:' + version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(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.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(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/ai-node-sdk: dependencies: