chore(core): Migrate @n8n/agents from Jest to Vitest (no-changelog) (#31529)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matsu 2026-06-02 12:17:03 +03:00 committed by GitHub
parent 01cc906ebd
commit 73c02cb1c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 416 additions and 345 deletions

View File

@ -1,7 +0,0 @@
/** @type {import('jest').Config} */
const base = require('../../../jest.config');
module.exports = {
...base,
testPathIgnorePatterns: [...(base.testPathIgnorePatterns || []), '/integration/'],
};

View File

@ -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:"
}
}

View File

@ -3,71 +3,73 @@ type MockExportResult = { code: number; error?: Error };
type MockExportCallback = (result: MockExportResult) => void;
type MockExporter = {
type: 'exporter';
export: jest.Mock<void, [unknown[], MockExportCallback]>;
shutdown: jest.Mock<Promise<void>, []>;
export: Mock<(...args: [unknown[], MockExportCallback]) => void>;
shutdown: Mock<(...args: []) => Promise<void>>;
};
const mockExporterInstances: MockExporter[] = [];
const mockBatchProcessorInputs: unknown[] = [];
const mockBatchProcessorInstances: Array<{
forceFlush: jest.Mock<Promise<void>, []>;
onStart: jest.Mock<void, [unknown, unknown]>;
onEnd: jest.Mock<void, [unknown]>;
shutdown: jest.Mock<Promise<void>, []>;
forceFlush: Mock<(...args: []) => Promise<void>>;
onStart: Mock<(...args: [unknown, unknown]) => void>;
onEnd: Mock<(...args: [unknown]) => void>;
shutdown: Mock<(...args: []) => Promise<void>>;
}> = [];
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<Promise<Record<string, string>>, []>()
const getHeaders = vi
.fn<(...args: []) => Promise<Record<string, string>>>()
.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<Promise<Record<string, string>>, []>()
const getHeaders = vi
.fn<(...args: []) => Promise<Record<string, string>>>()
.mockRejectedValueOnce(refreshError);
await new LangSmithTelemetry({

View File

@ -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();

View File

@ -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();

View File

@ -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);
}),
};

View File

@ -8,20 +8,20 @@ function makeFakeFilesystem(overrides: Partial<WorkspaceFilesystem> = {}): 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<WorkspaceSandbox> = {}): 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',

View File

@ -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<WorkspaceFilesystem> = {}): 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<void>((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 });

View File

@ -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<AiImport>('ai');
vi.mock('ai', async () => {
const actual = await vi.importActual<AiImport>('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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
expect((callArgs.providerOptions as Record<string, Record<string, unknown>>).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<string, unknown>;
const messages = callArgs.messages as Array<Record<string, unknown>>;
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<string, unknown>;
const messages = callArgs.messages as Array<Record<string, unknown>>;
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<Pick<BuiltMemory, 'saveEmbeddings' | 'queryEmbeddings'>>;
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<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
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<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
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<string, unknown>;
const expTelemetry = callArgs.experimental_telemetry as Record<string, unknown>;
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<unknown>;
@ -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<string, unknown>;
expect(callArgs.experimental_telemetry).toBeUndefined();
});

View File

@ -19,10 +19,12 @@ type GenerateObjectCall = {
type GenerateObjectResult = { object: unknown; usage?: { totalTokens?: number } };
const mockGenerateObject = jest.fn<Promise<GenerateObjectResult>, [GenerateObjectCall]>();
const { mockGenerateObject } = vi.hoisted(() => ({
mockGenerateObject: vi.fn<(...args: [GenerateObjectCall]) => Promise<GenerateObjectResult>>(),
}));
jest.mock('ai', () => {
const actual = jest.requireActual<typeof AiImport>('ai');
vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateObject: async (call: GenerateObjectCall): Promise<GenerateObjectResult> =>
@ -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 }) => {

View File

@ -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> = {}): 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({

View File

@ -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',

View File

@ -9,9 +9,9 @@ type ProviderOpts = {
headers?: Record<string, string>;
};
// 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',

View File

@ -18,10 +18,12 @@ import {
type GenerateTextCall = Record<string, unknown>;
type GenerateTextResult = { text: string; usage?: { totalTokens?: number } };
const mockGenerateText = jest.fn<Promise<GenerateTextResult>, [GenerateTextCall]>();
const { mockGenerateText } = vi.hoisted(() => ({
mockGenerateText: vi.fn<(...args: [GenerateTextCall]) => Promise<GenerateTextResult>>(),
}));
jest.mock('ai', () => {
const actual = jest.requireActual<typeof AiImport>('ai');
vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
@ -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,

View File

@ -18,10 +18,12 @@ import {
type GenerateTextCall = Record<string, unknown>;
type GenerateTextResult = { text: string; usage?: { totalTokens?: number } };
const mockGenerateText = jest.fn<Promise<GenerateTextResult>, [GenerateTextCall]>();
const { mockGenerateText } = vi.hoisted(() => ({
mockGenerateText: vi.fn<(...args: [GenerateTextCall]) => Promise<GenerateTextResult>>(),
}));
jest.mock('ai', () => {
const actual = jest.requireActual<typeof AiImport>('ai');
vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
@ -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,

View File

@ -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<BuiltObservationLogTaskLockStore['acquireObservationLogTaskLock']>,
Parameters<BuiltObservationLogTaskLockStore['acquireObservationLogTaskLock']>
const acquire = vi.fn<
(
...args: Parameters<BuiltObservationLogTaskLockStore['acquireObservationLogTaskLock']>
) => ReturnType<BuiltObservationLogTaskLockStore['acquireObservationLogTaskLock']>
>(
async (observationScopeId, taskKind, opts) =>
await Promise.resolve(lockHandle(observationScopeId, taskKind, opts.holderId)),

View File

@ -11,10 +11,12 @@ type GenerateTextCall = {
type GenerateTextResult = { text: string; usage?: { totalTokens?: number } };
const mockGenerateText = jest.fn<Promise<GenerateTextResult>, [GenerateTextCall]>();
const { mockGenerateText } = vi.hoisted(() => ({
mockGenerateText: vi.fn<(...args: [GenerateTextCall]) => Promise<GenerateTextResult>>(),
}));
jest.mock('ai', () => {
const actual = jest.requireActual<typeof AiImport>('ai');
vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
@ -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', {

View File

@ -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<AiImport>('ai');
vi.mock('ai', async () => {
const actual = await vi.importActual<AiImport>('ai');
return {
...actual,
tool: jest.fn((config: unknown) => config),
tool: vi.fn((config: unknown) => config),
jsonSchema: (schema: JSONSchema7) => jsonSchemaMock(schema),
};
});

View File

@ -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<AiImport>('ai');
vi.mock('ai', async () => {
const actual = await vi.importActual<AiImport>('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<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const secondCall = generateText.mock.calls[1][0] as Record<string, unknown>;
const firstTelemetry = firstCall.experimental_telemetry as Record<string, unknown>;
const secondTelemetry = secondCall.experimental_telemetry as Record<string, unknown>;

View File

@ -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',

View File

@ -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',

View File

@ -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(),
},
};

View File

@ -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> = {}): 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 () => {

View File

@ -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',

View File

@ -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"],

View File

@ -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()],
},
);

View File

@ -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: