chore: Migrate instance-ai from Jest to Vitest (#31463)

This commit is contained in:
Matsu 2026-06-03 09:48:27 +03:00 committed by GitHub
parent 24f27ed559
commit 25766222b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 2321 additions and 2115 deletions

View File

@ -1,3 +1,5 @@
import { vi } from 'vitest';
import { compareBuckets, type ExperimentBucket, type ScenarioCounts } from '../comparison/compare';
function bucket(
@ -133,7 +135,7 @@ describe('compareBuckets', () => {
});
it('drops unknown categories with a console warning, keeps all known categories', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const pr = bucket('pr', [s('a', 'happy', 8, 10)], {
totals: { '-': 5, builder_issue: 2 },
trialTotal: 10,

View File

@ -1,6 +1,9 @@
jest.mock('fs', () => ({
readdirSync: jest.fn(),
readFileSync: jest.fn(),
/* eslint-disable import-x/order */
import { vi } from 'vitest';
vi.mock('fs', () => ({
readdirSync: vi.fn(),
readFileSync: vi.fn(),
}));
import { readdirSync, readFileSync } from 'fs';
@ -8,8 +11,8 @@ import { readdirSync, readFileSync } from 'fs';
import { loadWorkflowTestCasesWithFiles } from '../data/workflows';
import { WorkflowTestCaseSchema } from '../data/workflows/schema';
const mockedReaddir = jest.mocked(readdirSync);
const mockedReadFile = jest.mocked(readFileSync);
const mockedReaddir = vi.mocked(readdirSync);
const mockedReadFile = vi.mocked(readFileSync);
const validFixture = () => ({
conversation: [{ role: 'user' as const, text: 'Build a thing' }],
@ -26,7 +29,7 @@ const validFixture = () => ({
});
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
mockedReaddir.mockReturnValue(['demo.json'] as unknown as ReturnType<typeof readdirSync>);
});

View File

@ -1,6 +1,9 @@
jest.mock('fs', () => ({
readdirSync: jest.fn(),
readFileSync: jest.fn(),
/* eslint-disable import-x/order */
import { vi } from 'vitest';
vi.mock('fs', () => ({
readdirSync: vi.fn(),
readFileSync: vi.fn(),
}));
import { readdirSync, readFileSync } from 'fs';
@ -8,8 +11,8 @@ import { jsonParse } from 'n8n-workflow';
import { loadWorkflowTestCasesWithFiles } from '../data/workflows';
const mockedReaddir = jest.mocked(readdirSync);
const mockedReadFile = jest.mocked(readFileSync);
const mockedReaddir = vi.mocked(readdirSync);
const mockedReadFile = vi.mocked(readFileSync);
const FAKE_FILES = [
'contact-form-automation.json',
@ -37,7 +40,7 @@ const STUB_TEST_CASE = JSON.stringify({
});
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
mockedReaddir.mockReturnValue(FAKE_FILES as unknown as ReturnType<typeof readdirSync>);
mockedReadFile.mockReturnValue(STUB_TEST_CASE);
});

View File

@ -1,5 +1,9 @@
jest.mock('../data/workflows', () => ({
loadWorkflowTestCasesWithFiles: jest.fn(),
/* eslint-disable import-x/order */
import { vi } from 'vitest';
import type { Mock } from 'vitest';
vi.mock('../data/workflows', () => ({
loadWorkflowTestCasesWithFiles: vi.fn(),
}));
import type { Client } from 'langsmith';
@ -9,7 +13,7 @@ import { loadWorkflowTestCasesWithFiles } from '../data/workflows';
import type { EvalLogger } from '../harness/logger';
import { syncDataset } from '../langsmith/dataset-sync';
const mockedLoad = jest.mocked(loadWorkflowTestCasesWithFiles);
const mockedLoad = vi.mocked(loadWorkflowTestCasesWithFiles);
function scenarioFixture(testCaseFile: string, scenarioName: string) {
return {
@ -61,13 +65,19 @@ type UpsertArg = Array<{ id: string; inputs: Record<string, unknown> }>;
function buildClient(existing: Example[] = []): {
client: Client;
createExamples: jest.Mock<Promise<void>, [UpsertArg]>;
updateExamples: jest.Mock<Promise<void>, [UpsertArg]>;
deleteExamples: jest.Mock<Promise<void>, [string[]]>;
createExamples: Mock<(...args: [UpsertArg]) => Promise<void>>;
updateExamples: Mock<(...args: [UpsertArg]) => Promise<void>>;
deleteExamples: Mock<(...args: [string[]]) => Promise<void>>;
} {
const createExamples = jest.fn<Promise<void>, [UpsertArg]>().mockResolvedValue(undefined);
const updateExamples = jest.fn<Promise<void>, [UpsertArg]>().mockResolvedValue(undefined);
const deleteExamples = jest.fn<Promise<void>, [string[]]>().mockResolvedValue(undefined);
const createExamples = vi
.fn<(...args: [UpsertArg]) => Promise<void>>()
.mockResolvedValue(undefined);
const updateExamples = vi
.fn<(...args: [UpsertArg]) => Promise<void>>()
.mockResolvedValue(undefined);
const deleteExamples = vi
.fn<(...args: [string[]]) => Promise<void>>()
.mockResolvedValue(undefined);
async function* listExamples() {
await Promise.resolve();
@ -75,10 +85,10 @@ function buildClient(existing: Example[] = []): {
}
const client = {
hasDataset: jest.fn().mockResolvedValue(true),
readDataset: jest.fn().mockResolvedValue({ id: 'dataset-1' }),
createDataset: jest.fn(),
listExamples: jest.fn().mockImplementation(listExamples),
hasDataset: vi.fn().mockResolvedValue(true),
readDataset: vi.fn().mockResolvedValue({ id: 'dataset-1' }),
createDataset: vi.fn(),
listExamples: vi.fn().mockImplementation(listExamples),
createExamples,
updateExamples,
deleteExamples,
@ -88,17 +98,17 @@ function buildClient(existing: Example[] = []): {
}
const logger: EvalLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
verbose: jest.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
verbose: vi.fn(),
} as unknown as EvalLogger;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
describe('syncDataset', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
it('creates new examples with random UUIDs when they are not already in the dataset', async () => {

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
// `SimpleWorkflow` (the return type of `normalizeWorkflow`) is imported from
// `ai-workflow-builder.ee` via deep relative paths into source files that use
// a `@/*` path alias. That alias collides with instance-ai's own `@/*` mapping

View File

@ -1,6 +1,8 @@
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { vi } from 'vitest';
import type { Mock } from 'vitest';
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import type { EvalLogger } from '../harness/logger';
@ -99,7 +101,7 @@ describe('fetchPrebuiltBuild', () => {
isVerbose: false,
};
function makeClient(getWorkflow: jest.Mock): N8nClient {
function makeClient(getWorkflow: Mock): N8nClient {
// Only the methods used by fetchPrebuiltBuild — narrow cast in test code is fine.
return { getWorkflow } as unknown as N8nClient;
}
@ -111,7 +113,7 @@ describe('fetchPrebuiltBuild', () => {
nodes: [{ id: 'n1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }],
connections: {},
} as unknown as WorkflowResponse;
const getWorkflow = jest.fn().mockResolvedValue(fakeWorkflow);
const getWorkflow = vi.fn().mockResolvedValue(fakeWorkflow);
const client = makeClient(getWorkflow);
const result = await fetchPrebuiltBuild(client, 'W123', silentLogger);
@ -127,7 +129,7 @@ describe('fetchPrebuiltBuild', () => {
});
it('returns a failed BuildResult with the workflow ID in the error when fetch throws', async () => {
const getWorkflow = jest.fn().mockRejectedValue(new Error('HTTP 404 Not Found'));
const getWorkflow = vi.fn().mockRejectedValue(new Error('HTTP 404 Not Found'));
const client = makeClient(getWorkflow);
const result = await fetchPrebuiltBuild(client, 'Wstale', silentLogger);
@ -141,7 +143,7 @@ describe('fetchPrebuiltBuild', () => {
});
it('coerces non-Error rejection values into a string in the error message', async () => {
const getWorkflow = jest.fn().mockRejectedValue('plain string failure');
const getWorkflow = vi.fn().mockRejectedValue('plain string failure');
const client = makeClient(getWorkflow);
const result = await fetchPrebuiltBuild(client, 'Wodd', silentLogger);

View File

@ -1,3 +1,6 @@
import { vi } from 'vitest';
import type { Mock } from 'vitest';
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import type { EvalLogger } from '../harness/logger';
import { runWorkflowTestCase } from '../harness/runner';
@ -28,16 +31,16 @@ const silentLogger: EvalLogger = {
isVerbose: false,
};
function makeClient(overrides: Partial<Record<keyof N8nClient, jest.Mock>> = {}): {
function makeClient(overrides: Partial<Record<keyof N8nClient, Mock>> = {}): {
client: N8nClient;
mocks: Record<string, jest.Mock>;
mocks: Record<string, Mock>;
} {
const mocks: Record<string, jest.Mock> = {
getWorkflow: jest.fn(),
sendMessage: jest.fn(),
deleteWorkflow: jest.fn().mockResolvedValue(undefined),
deleteDataTable: jest.fn().mockResolvedValue(undefined),
listDataTables: jest.fn().mockResolvedValue([]),
const mocks: Record<string, Mock> = {
getWorkflow: vi.fn(),
sendMessage: vi.fn(),
deleteWorkflow: vi.fn().mockResolvedValue(undefined),
deleteDataTable: vi.fn().mockResolvedValue(undefined),
listDataTables: vi.fn().mockResolvedValue([]),
...overrides,
};
return { client: mocks as unknown as N8nClient, mocks };
@ -64,7 +67,7 @@ describe('runWorkflowTestCase with prebuiltWorkflowId', () => {
} as unknown as WorkflowResponse;
const { client, mocks } = makeClient({
getWorkflow: jest.fn().mockResolvedValue(fakeWorkflow),
getWorkflow: vi.fn().mockResolvedValue(fakeWorkflow),
});
const result = await runWorkflowTestCase({
@ -96,7 +99,7 @@ describe('runWorkflowTestCase with prebuiltWorkflowId', () => {
it('reports build failure with the workflow ID when fetch fails', async () => {
const { client, mocks } = makeClient({
getWorkflow: jest.fn().mockRejectedValue(new Error('HTTP 404')),
getWorkflow: vi.fn().mockRejectedValue(new Error('HTTP 404')),
});
const result = await runWorkflowTestCase({

View File

@ -1,5 +1,7 @@
jest.mock('../binaryChecks/index', () => ({
runBinaryChecks: jest.fn(),
import { vi } from 'vitest';
vi.mock('../binaryChecks/index', () => ({
runBinaryChecks: vi.fn(),
}));
import { runBinaryChecks } from '../binaryChecks/index';
@ -8,7 +10,7 @@ import type { WorkflowResponse } from '../clients/n8n-client';
import type { EvalLogger } from '../harness/logger';
import { runWorkflowChecks } from '../harness/runner';
const mockedRunBinaryChecks = jest.mocked(runBinaryChecks);
const mockedRunBinaryChecks = vi.mocked(runBinaryChecks);
const silentLogger: EvalLogger = {
info: () => {},
@ -19,6 +21,7 @@ const silentLogger: EvalLogger = {
isVerbose: false,
};
// @ts-expect-error - Partial
const fakeWorkflow: WorkflowResponse = {
id: 'wf-1',
name: 'Demo',
@ -45,7 +48,7 @@ const sampleOutcomes: CheckOutcome[] = [
];
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
describe('runWorkflowChecks', () => {

View File

@ -1,10 +1,10 @@
jest.mock('../harness/runner', () => ({
buildWorkflow: jest.fn(),
cleanupBuild: jest.fn(),
vi.mock('../harness/runner', () => ({
buildWorkflow: vi.fn(),
cleanupBuild: vi.fn(),
}));
jest.mock('../binaryChecks/index', () => ({
runBinaryChecks: jest.fn(),
vi.mock('../binaryChecks/index', () => ({
runBinaryChecks: vi.fn(),
}));
import { runBinaryChecks } from '../binaryChecks/index';
@ -12,9 +12,9 @@ import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import { buildWorkflow, cleanupBuild, type BuildResult } from '../harness/runner';
import { runWorkflowBuildEval } from '../subagent/runner';
const mockedBuildWorkflow = jest.mocked(buildWorkflow);
const mockedCleanupBuild = jest.mocked(cleanupBuild);
const mockedRunBinaryChecks = jest.mocked(runBinaryChecks);
const mockedBuildWorkflow = vi.mocked(buildWorkflow);
const mockedCleanupBuild = vi.mocked(cleanupBuild);
const mockedRunBinaryChecks = vi.mocked(runBinaryChecks);
function makeWorkflow(): WorkflowResponse {
return {
@ -33,7 +33,7 @@ function makeClient(): N8nClient {
describe('runWorkflowBuildEval', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
mockedCleanupBuild.mockResolvedValue(undefined);
mockedRunBinaryChecks.mockResolvedValue({
feedback: [

View File

@ -8,7 +8,7 @@
"lib": ["es2023"],
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"types": ["node", "jest"]
"types": ["node", "vitest/globals"]
},
"include": ["**/*.ts"]
}

View File

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

View File

@ -7,8 +7,9 @@
"build": "tsc -p ./tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"format": "biome format --write src",
"format:check": "biome ci src",
"test": "jest",
"test:unit": "jest",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"eval:instance-ai": "tsx evaluations/cli/index.ts",
@ -82,9 +83,13 @@
"@langchain/core": "catalog:",
"@n8n/ai-workflow-builder": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/luxon": "3.2.0",
"@types/psl": "1.1.3",
"@types/turndown": "^5.0.5",
"tsx": "catalog:"
"@vitest/coverage-v8": "catalog:",
"tsx": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
}
}

View File

@ -1,35 +1,38 @@
/* eslint-disable import-x/order */
import type { Mock } from 'vitest';
const mockAgentInstances: Array<{
model: jest.Mock;
instructions: jest.Mock;
tool: jest.Mock;
deferredTool: jest.Mock;
skills: jest.Mock;
checkpoint: jest.Mock;
memory: jest.Mock;
telemetry: jest.Mock;
workspace: jest.Mock;
model: Mock;
instructions: Mock;
tool: Mock;
deferredTool: Mock;
skills: Mock;
checkpoint: Mock;
memory: Mock;
telemetry: Mock;
workspace: Mock;
}> = [];
const mockMemoryBuilder = {
storage: jest.fn(),
observationalMemory: jest.fn(),
build: jest.fn(),
storage: vi.fn(),
observationalMemory: vi.fn(),
build: vi.fn(),
};
jest.mock('@n8n/agents', () => ({
Agent: jest.fn().mockImplementation(function Agent(this: (typeof mockAgentInstances)[number]) {
this.model = jest.fn().mockReturnThis();
this.instructions = jest.fn().mockReturnThis();
this.tool = jest.fn().mockReturnThis();
this.deferredTool = jest.fn().mockReturnThis();
this.skills = jest.fn().mockReturnThis();
this.checkpoint = jest.fn().mockReturnThis();
this.memory = jest.fn().mockReturnThis();
this.telemetry = jest.fn().mockReturnThis();
this.workspace = jest.fn().mockReturnThis();
vi.mock('@n8n/agents', () => ({
Agent: vi.fn().mockImplementation(function Agent(this: (typeof mockAgentInstances)[number]) {
this.model = vi.fn().mockReturnThis();
this.instructions = vi.fn().mockReturnThis();
this.tool = vi.fn().mockReturnThis();
this.deferredTool = vi.fn().mockReturnThis();
this.skills = vi.fn().mockReturnThis();
this.checkpoint = vi.fn().mockReturnThis();
this.memory = vi.fn().mockReturnThis();
this.telemetry = vi.fn().mockReturnThis();
this.workspace = vi.fn().mockReturnThis();
mockAgentInstances.push(this);
}),
Memory: jest.fn().mockImplementation(function Memory() {
Memory: vi.fn().mockImplementation(function Memory() {
return mockMemoryBuilder;
}),
}));
@ -37,12 +40,12 @@ jest.mock('@n8n/agents', () => ({
const mockBuiltTool = (name: string, marker?: string) => ({
name,
description: name,
handler: jest.fn(),
handler: vi.fn(),
marker,
});
jest.mock('../../tools', () => ({
createAllTools: jest.fn(
vi.mock('../../tools', () => ({
createAllTools: vi.fn(
(context: { runLabel?: string }) =>
new Map([
['workflows', mockBuiltTool(`workflows-${context.runLabel ?? 'unknown'}`)],
@ -51,7 +54,7 @@ jest.mock('../../tools', () => ({
['nodes', mockBuiltTool(`nodes-${context.runLabel ?? 'unknown'}`)],
]),
),
createOrchestratorDomainTools: jest.fn(
createOrchestratorDomainTools: vi.fn(
(context: { runLabel?: string }) =>
new Map([
['workflows', mockBuiltTool(`workflows-${context.runLabel ?? 'unknown'}`)],
@ -62,7 +65,7 @@ jest.mock('../../tools', () => ({
['build-workflow', mockBuiltTool(`build-workflow-${context.runLabel ?? 'unknown'}`)],
]),
),
createOrchestrationTools: jest.fn(
createOrchestrationTools: vi.fn(
(context: { runId: string }) =>
new Map([
['plan', mockBuiltTool(`plan-${context.runId}`)],
@ -73,43 +76,36 @@ jest.mock('../../tools', () => ({
),
}));
jest.mock('../../tools/filesystem/create-tools-from-mcp-server', () => ({
createToolsFromLocalMcpServer: jest.fn().mockReturnValue(new Map()),
vi.mock('../../tools/filesystem/create-tools-from-mcp-server', () => ({
createToolsFromLocalMcpServer: vi.fn().mockReturnValue(new Map()),
}));
jest.mock('../../tracing/langsmith-tracing', () => ({
buildAgentTraceInputs: jest.fn().mockReturnValue({}),
mergeTraceRunInputs: jest.fn(),
vi.mock('../../tracing/langsmith-tracing', () => ({
buildAgentTraceInputs: vi.fn().mockReturnValue({}),
mergeTraceRunInputs: vi.fn(),
}));
jest.mock('../system-prompt', () => ({
getSystemPrompt: jest.fn().mockReturnValue('system prompt'),
vi.mock('../system-prompt', () => ({
getSystemPrompt: vi.fn().mockReturnValue('system prompt'),
}));
const { createInstanceAgent } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../instance-agent') as typeof import('../instance-agent');
const { Agent, Memory } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@n8n/agents') as {
Agent: jest.Mock;
Memory: jest.Mock;
};
const { createToolsFromLocalMcpServer } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../../tools/filesystem/create-tools-from-mcp-server') as {
createToolsFromLocalMcpServer: jest.Mock;
};
const { createOrchestratorDomainTools } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../../tools') as { createOrchestratorDomainTools: jest.Mock };
import { Agent as AgentImport, Memory as MemoryImport } from '@n8n/agents';
import { createOrchestratorDomainTools as createOrchestratorDomainToolsImport } from '../../tools';
import { createToolsFromLocalMcpServer as createToolsFromLocalMcpServerImport } from '../../tools/filesystem/create-tools-from-mcp-server';
import { createInstanceAgent } from '../instance-agent';
const Agent = AgentImport as unknown as Mock;
const Memory = MemoryImport as unknown as Mock;
const createToolsFromLocalMcpServer = createToolsFromLocalMcpServerImport as unknown as Mock;
const createOrchestratorDomainTools = createOrchestratorDomainToolsImport as unknown as Mock;
function createMcpManagerStub(
regularTools: Map<string, ReturnType<typeof mockBuiltTool>> = new Map(),
) {
return {
getRegularTools: jest.fn().mockResolvedValue(regularTools),
disconnect: jest.fn().mockResolvedValue(undefined),
getRegularTools: vi.fn().mockResolvedValue(regularTools),
disconnect: vi.fn().mockResolvedValue(undefined),
};
}
@ -281,8 +277,8 @@ describe('createInstanceAgent', () => {
orchestrationContext: {
runId: 'trace-test',
tracing: {
getTelemetry: jest.fn().mockReturnValue(telemetry),
wrapTools: jest.fn((tools: unknown) => tools),
getTelemetry: vi.fn().mockReturnValue(telemetry),
wrapTools: vi.fn((tools: unknown) => tools),
},
},
memoryConfig: {},
@ -314,7 +310,7 @@ describe('createInstanceAgent', () => {
},
],
},
loadSkill: jest.fn(),
loadSkill: vi.fn(),
};
await createInstanceAgent({
@ -349,7 +345,7 @@ describe('createInstanceAgent', () => {
const memoryConfig = { storage: { id: 'memory-store' } } as never;
const localMcpServer = {
getToolsByCategory: jest
getToolsByCategory: vi
.fn()
.mockReturnValue([{ name: 'browser_connect' }, { name: 'browser_navigate' }]),
};
@ -378,7 +374,7 @@ describe('createInstanceAgent', () => {
it('prefers local gateway tools over external MCP tools when names collide', async () => {
const memoryConfig = {} as never;
const localMcpServer = {
getToolsByCategory: jest.fn().mockReturnValue([]),
getToolsByCategory: vi.fn().mockReturnValue([]),
};
const localTools = new Map([['shared_tool', mockBuiltTool('shared_tool', 'local-shared')]]);
const externalTools = new Map([

View File

@ -24,7 +24,7 @@ describe('MCP tool name validation', () => {
it('still skips exact normalized name collisions with native tools', () => {
const target = createToolRegistry();
const warn = jest.fn();
const warn = vi.fn();
addSafeMcpTools(target, makeTools(['work-flows']), {
source: 'external MCP',

View File

@ -307,7 +307,7 @@ describe('sanitizeMcpToolSchemas', () => {
});
it('should remove only the offending MCP tool when one schema is too deep', () => {
const onError = jest.fn();
const onError = vi.fn();
const tools = makeTools({
validTool: { input: z.object({ name: z.string() }) },
deepTool: { input: makeDeepObject(4) },
@ -335,7 +335,7 @@ describe('sanitizeMcpToolSchemas', () => {
});
it('should bound lazy schemas', () => {
const onError = jest.fn();
const onError = vi.fn();
const tools = makeTools({
lazyTool: { input: z.object({ payload: z.lazy(() => makeWideObject(4)) }) },
});
@ -352,7 +352,7 @@ describe('sanitizeMcpToolSchemas', () => {
});
it('should remove tools containing unsupported tuple or intersection schemas', () => {
const onError = jest.fn();
const onError = vi.fn();
const tools = makeTools({
tupleTool: { input: z.object({ pair: z.tuple([z.string(), z.null()]) }) },
intersectionTool: {
@ -383,7 +383,7 @@ describe('sanitizeMcpToolSchemas', () => {
});
it('should remove tools containing unsupported wrapper types', () => {
const onError = jest.fn();
const onError = vi.fn();
const tools = makeTools({
mapTool: { input: z.object({ values: z.map(z.string(), z.string()) }) },
});
@ -398,7 +398,7 @@ describe('sanitizeMcpToolSchemas', () => {
});
it('should remove a shallow MCP tool with too many object properties', () => {
const onError = jest.fn();
const onError = vi.fn();
const tools = makeTools({
wideTool: { input: makeWideObject(4) },
});
@ -417,7 +417,7 @@ describe('sanitizeMcpToolSchemas', () => {
});
it('should remove a shallow MCP tool with too many union options', () => {
const onError = jest.fn();
const onError = vi.fn();
const tools = makeTools({
unionTool: {
input: z.object({
@ -440,7 +440,7 @@ describe('sanitizeMcpToolSchemas', () => {
});
it('should remove an MCP tool that exceeds the total schema node budget', () => {
const onError = jest.fn();
const onError = vi.fn();
const tools = makeTools({
nodeBudgetTool: {
input: z.object({
@ -463,7 +463,7 @@ describe('sanitizeMcpToolSchemas', () => {
});
it('reports raw JSON output schema limit errors under the output schema path', () => {
const onError = jest.fn();
const onError = vi.fn();
const outputTool: BuiltTool = {
name: 'outputTool',
description: 'outputTool',

View File

@ -19,14 +19,14 @@ function createSandboxWorkspace(files: Map<string, string>): {
const workspace: SandboxWorkspace = {
filesystem: {
provider: 'local',
writeFile: jest.fn(async (path: string, content: string | Buffer) => {
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
await Promise.resolve();
}),
mkdir: jest.fn(async () => await Promise.resolve()),
mkdir: vi.fn(async () => await Promise.resolve()),
},
sandbox: {
executeCommand: jest.fn(async (command: string) => {
executeCommand: vi.fn(async (command: string) => {
const readMatch = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
if (readMatch) {
const content = files.get(readMatch[1]);

View File

@ -1,30 +1,31 @@
jest.mock('@n8n/agents', () => ({
McpClient: jest.fn().mockImplementation(() => ({
listTools: jest.fn().mockResolvedValue([]),
close: jest.fn().mockResolvedValue(undefined),
})),
/* eslint-disable import-x/order */
import type { Mock, Mocked } from 'vitest';
vi.mock('@n8n/agents', () => ({
McpClient: vi.fn(function () {
return {
listTools: vi.fn().mockResolvedValue([]),
close: vi.fn().mockResolvedValue(undefined),
};
}),
}));
jest.mock('../../agent/sanitize-mcp-schemas', () => ({
sanitizeMcpToolSchemas: jest.fn((tools: unknown) => tools),
vi.mock('../../agent/sanitize-mcp-schemas', () => ({
sanitizeMcpToolSchemas: vi.fn((tools: unknown) => tools),
}));
import { McpClient } from '@n8n/agents';
import { createResultError, createResultOk, UserError } from 'n8n-workflow';
import { sanitizeMcpToolSchemas } from '../../agent/sanitize-mcp-schemas';
import type { SsrfUrlValidator } from '../mcp-client-manager';
import { McpClientManager } from '../mcp-client-manager';
const { McpClient: mockedMcpClient } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@n8n/agents') as { McpClient: jest.Mock };
const { sanitizeMcpToolSchemas: mockedSanitizeMcpToolSchemas } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../../agent/sanitize-mcp-schemas') as {
sanitizeMcpToolSchemas: jest.Mock;
};
const mockedMcpClient = McpClient as unknown as Mock;
const mockedSanitizeMcpToolSchemas = sanitizeMcpToolSchemas as unknown as Mock;
interface LoggerMock {
warn: jest.Mock;
warn: Mock;
}
interface SanitizeOptions {
@ -41,15 +42,15 @@ interface SanitizeOptions {
}) => void;
}
function createValidatorMock(): jest.Mocked<SsrfUrlValidator> {
function createValidatorMock(): Mocked<SsrfUrlValidator> {
return {
validateUrl: jest.fn().mockResolvedValue(createResultOk(undefined)),
} as jest.Mocked<SsrfUrlValidator>;
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
} as Mocked<SsrfUrlValidator>;
}
describe('McpClientManager', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
describe('protocol whitelist (always-on)', () => {
@ -108,7 +109,7 @@ describe('McpClientManager', () => {
describe('server and schema filtering', () => {
it('skips external MCP servers with unsafe names', async () => {
const logger: LoggerMock = { warn: jest.fn() };
const logger: LoggerMock = { warn: vi.fn() };
const manager = new McpClientManager();
await manager.getRegularTools(
@ -138,7 +139,7 @@ describe('McpClientManager', () => {
});
it('logs tools skipped during schema sanitization', async () => {
const logger: LoggerMock = { warn: jest.fn() };
const logger: LoggerMock = { warn: vi.fn() };
mockedSanitizeMcpToolSchemas.mockImplementationOnce(
(_tools: unknown, options?: SanitizeOptions) => {
options?.onError?.({
@ -227,7 +228,7 @@ describe('McpClientManager', () => {
expect(mockedMcpClient).toHaveBeenCalledTimes(1);
const disconnectMocks = mockedMcpClient.mock.results.map(
(r) => (r.value as { close: jest.Mock }).close,
(r) => (r.value as { close: Mock }).close,
);
await manager.disconnect();
@ -306,10 +307,12 @@ describe('McpClientManager', () => {
const manager = new McpClientManager();
const configs = [{ name: 'a', url: 'https://a.example.com/' }];
mockedMcpClient.mockImplementationOnce(() => ({
listTools: jest.fn().mockRejectedValue(new Error('boom')),
close: jest.fn().mockResolvedValue(undefined),
}));
mockedMcpClient.mockImplementationOnce(function () {
return {
listTools: vi.fn().mockRejectedValue(new Error('boom')),
close: vi.fn().mockResolvedValue(undefined),
};
});
await expect(manager.getRegularTools(configs)).rejects.toThrow('boom');
// In-flight entry must be cleared so a retry actually re-attempts.
@ -334,10 +337,12 @@ describe('McpClientManager', () => {
const configs = [{ name: 'a', url: 'https://a.example.com/' }];
const deferred = deferListTools();
mockedMcpClient.mockImplementationOnce(() => ({
listTools: jest.fn().mockReturnValue(deferred.promise),
close: jest.fn().mockResolvedValue(undefined),
}));
mockedMcpClient.mockImplementationOnce(function () {
return {
listTools: vi.fn().mockReturnValue(deferred.promise),
close: vi.fn().mockResolvedValue(undefined),
};
});
const stranded = manager.getRegularTools(configs);
// Yield so connectAndListTools registers the client before we tear down.

View File

@ -1,9 +1,11 @@
import { extractDocxText } from '../docx-parser';
import { MAX_DECODED_SIZE_BYTES } from '../structured-file-parser';
const mockExtractRawText = jest.fn<Promise<{ value: string; messages: unknown[] }>, [unknown]>();
const { mockExtractRawText } = vi.hoisted(() => ({
mockExtractRawText: vi.fn<(arg: unknown) => Promise<{ value: string; messages: unknown[] }>>(),
}));
jest.mock('mammoth', () => ({
vi.mock('mammoth', () => ({
__esModule: true,
default: {
extractRawText: async (input: { buffer: Buffer }) => await mockExtractRawText(input),

View File

@ -1,17 +1,19 @@
import { extractPdfText } from '../pdf-parser';
import { MAX_DECODED_SIZE_BYTES } from '../structured-file-parser';
const mockGetText = jest.fn<Promise<{ text: string; total: number }>, []>();
const mockDestroy = jest.fn<Promise<void>, []>();
jest.mock('pdf-parse', () => ({
__esModule: true,
PDFParse: jest.fn().mockImplementation(() => ({
getText: mockGetText,
destroy: mockDestroy,
})),
const { mockGetText, mockDestroy } = vi.hoisted(() => ({
mockGetText: vi.fn<() => Promise<{ text: string; total: number }>>(),
mockDestroy: vi.fn<() => Promise<void>>(),
}));
vi.mock('pdf-parse', () => {
class PDFParse {
getText = mockGetText;
destroy = mockDestroy;
}
return { PDFParse };
});
function toBase64(content: string | Buffer): string {
const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
return buf.toString('base64');

View File

@ -97,7 +97,7 @@ describe('validateAttachmentMimeTypes', () => {
{ data: '', mimeType: 'application/zip', fileName: 'a.zip' },
{ data: '', mimeType: 'video/mp4', fileName: 'b.mp4' },
]);
fail('expected error to be thrown');
expect.fail('expected error to be thrown');
} catch (caught) {
expect(caught).toBeInstanceOf(UnsupportedAttachmentError);
const error = caught as UnsupportedAttachmentError;

View File

@ -17,7 +17,7 @@ type CsvParseFn = typeof csvParse;
let csvParseFnCached: CsvParseFn | undefined;
function getCsvParse(): CsvParseFn {
if (!csvParseFnCached) {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('csv-parse/sync') as { parse: CsvParseFn };
csvParseFnCached = mod.parse;
}

View File

@ -1,14 +1,16 @@
import type { Mocked } from 'vitest';
import type { PlannedTaskStorage } from '../../storage/planned-task-storage';
import type { PlannedTask, PlannedTaskGraph, PlannedTaskRecord } from '../../types';
import { PlannedTaskCoordinator } from '../planned-task-service';
function makeStorage(): jest.Mocked<PlannedTaskStorage> {
function makeStorage(): Mocked<PlannedTaskStorage> {
return {
get: jest.fn(),
save: jest.fn(),
update: jest.fn(),
clear: jest.fn(),
} as unknown as jest.Mocked<PlannedTaskStorage>;
get: vi.fn(),
save: vi.fn(),
update: vi.fn(),
clear: vi.fn(),
} as unknown as Mocked<PlannedTaskStorage>;
}
function makeTask(overrides: Partial<PlannedTask> = {}): PlannedTask {
@ -44,11 +46,11 @@ function makeTaskRecord(overrides: Partial<PlannedTaskRecord> = {}): PlannedTask
}
describe('PlannedTaskCoordinator', () => {
let storage: jest.Mocked<PlannedTaskStorage>;
let storage: Mocked<PlannedTaskStorage>;
let coordinator: PlannedTaskCoordinator;
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
storage = makeStorage();
coordinator = new PlannedTaskCoordinator(storage);
});

View File

@ -17,7 +17,7 @@ function makeSpawnOptions(
runId: 'run-1',
role: 'builder',
agentId: 'agent-1',
run: jest.fn().mockResolvedValue('done'),
run: vi.fn().mockResolvedValue('done'),
...overrides,
};
}
@ -39,8 +39,8 @@ describe('BackgroundTaskManager', () => {
});
it('fails and settles idle running tasks', async () => {
const onFailed = jest.fn((_task: ManagedBackgroundTask) => undefined);
const onSettled = jest.fn((_task: ManagedBackgroundTask) => undefined);
const onFailed = vi.fn((_task: ManagedBackgroundTask) => undefined);
const onSettled = vi.fn((_task: ManagedBackgroundTask) => undefined);
let signal: AbortSignal | undefined;
manager.spawn(
@ -132,8 +132,8 @@ describe('BackgroundTaskManager', () => {
});
it('rejects spawn when concurrent limit is reached', () => {
const onLimitReached = jest.fn();
const createTraceContext = jest.fn();
const onLimitReached = vi.fn();
const createTraceContext = vi.fn();
manager.spawn(
makeSpawnOptions({ taskId: 't1', run: async () => await new Promise(() => {}) }),
@ -156,8 +156,8 @@ describe('BackgroundTaskManager', () => {
it('creates lazy trace context only after a task is accepted', async () => {
const traceContext = { projectName: 'instance-ai' } as never;
const createTraceContext = jest.fn().mockResolvedValue(traceContext);
const run = jest.fn().mockResolvedValue('done');
const createTraceContext = vi.fn().mockResolvedValue(traceContext);
const run = vi.fn().mockResolvedValue('done');
manager.spawn(makeSpawnOptions({ createTraceContext, run }));
await flushPromises();
@ -172,8 +172,8 @@ describe('BackgroundTaskManager', () => {
});
it('calls onCompleted and onSettled when run resolves with string', async () => {
const onCompleted = jest.fn();
const onSettled = jest.fn();
const onCompleted = vi.fn();
const onSettled = vi.fn();
const { promise, resolve } = createDeferred<string>();
manager.spawn(
@ -194,7 +194,7 @@ describe('BackgroundTaskManager', () => {
});
it('calls onCompleted with structured result', async () => {
const onCompleted = jest.fn();
const onCompleted = vi.fn();
const { promise, resolve } = createDeferred<string | BackgroundTaskResult>();
manager.spawn(
@ -217,8 +217,8 @@ describe('BackgroundTaskManager', () => {
});
it('calls onFailed and onSettled when run rejects', async () => {
const onFailed = jest.fn();
const onSettled = jest.fn();
const onFailed = vi.fn();
const onSettled = vi.fn();
const { promise, reject } = createDeferred<string | BackgroundTaskResult>();
manager.spawn(
@ -239,7 +239,7 @@ describe('BackgroundTaskManager', () => {
});
it('does not call onFailed when aborted', async () => {
const onFailed = jest.fn();
const onFailed = vi.fn();
const { promise, reject } = createDeferred<string | BackgroundTaskResult>();
manager.spawn(
@ -257,7 +257,7 @@ describe('BackgroundTaskManager', () => {
});
it('does not call onSettled when aborted', async () => {
const onSettled = jest.fn();
const onSettled = vi.fn();
const { promise, reject } = createDeferred<string | BackgroundTaskResult>();
manager.spawn(
@ -298,7 +298,7 @@ describe('BackgroundTaskManager', () => {
);
expect(first.status).toBe('started');
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
const run = vi.fn(async (): Promise<string> => await new Promise(() => {}));
const second = manager.spawn(
makeSpawnOptions({
taskId: 'second',
@ -323,7 +323,7 @@ describe('BackgroundTaskManager', () => {
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-trace' },
}),
);
const createTraceContext = jest.fn();
const createTraceContext = vi.fn();
const second = manager.spawn(
makeSpawnOptions({
@ -369,7 +369,7 @@ describe('BackgroundTaskManager', () => {
}),
);
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
const run = vi.fn(async (): Promise<string> => await new Promise(() => {}));
const second = manager.spawn(
makeSpawnOptions({
taskId: 'second',
@ -400,7 +400,7 @@ describe('BackgroundTaskManager', () => {
);
expect(first.status).toBe('started');
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
const run = vi.fn(async (): Promise<string> => await new Promise(() => {}));
const second = manager.spawn(
makeSpawnOptions({
taskId: 'task-B',
@ -447,7 +447,7 @@ describe('BackgroundTaskManager', () => {
manager.spawn({ ...filler, taskId: 't2' });
manager.spawn({ ...filler, taskId: 't3' });
const onLimitReached = jest.fn();
const onLimitReached = vi.fn();
const result = manager.spawn(
makeSpawnOptions({
taskId: 't4',

View File

@ -3,17 +3,17 @@ import { executeResumableStream, normalizeStreamSource } from '../resumable-stre
function createEventBus() {
return {
publish: jest.fn(),
subscribe: jest.fn(),
getEventsAfter: jest.fn(),
getNextEventId: jest.fn(),
getEventsForRun: jest.fn().mockReturnValue([]),
getEventsForRuns: jest.fn().mockReturnValue([]),
publish: vi.fn(),
subscribe: vi.fn(),
getEventsAfter: vi.fn(),
getNextEventId: vi.fn(),
getEventsForRun: vi.fn().mockReturnValue([]),
getEventsForRuns: vi.fn().mockReturnValue([]),
};
}
function createLogger() {
return { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
}
async function* fromChunks(chunks: unknown[]) {
@ -92,7 +92,7 @@ describe('normalizeStreamSource', () => {
},
},
]),
getState: jest.fn(),
getState: vi.fn(),
});
expect(source.runId).toBe('agent-run-1');
@ -178,7 +178,7 @@ describe('executeResumableStream', () => {
});
it('reports liveness activity for each consumed chunk', async () => {
const onActivity = jest.fn();
const onActivity = vi.fn();
await executeResumableStream({
agent: {},
@ -234,9 +234,7 @@ describe('executeResumableStream', () => {
control: { mode: 'manual' },
});
const publishedEvents = eventBus.publish.mock.calls.map(
([, event]: [string, PublishedEvent]) => event,
);
const publishedEvents = eventBus.publish.mock.calls.map(([, event]) => event as PublishedEvent);
const firstText = publishedEvents.find((event) => event.payload?.text === 'First');
const toolCall = publishedEvents.find((event) => event.type === 'tool-call');
const firstStepContinuation = publishedEvents.find((event) => event.payload?.text === ' step');
@ -250,11 +248,11 @@ describe('executeResumableStream', () => {
it('auto-resumes suspended streams and passes drained corrections to resume data', async () => {
const eventBus = createEventBus();
const resume = jest.fn().mockResolvedValue({
const resume = vi.fn().mockResolvedValue({
runId: 'agent-run-2',
stream: readableFromChunks([textChunk('Done.')]),
});
const waitForConfirmation = jest.fn().mockResolvedValue({ approved: true });
const waitForConfirmation = vi.fn().mockResolvedValue({ approved: true });
let hasDrainedCorrection = false;
const result = await executeResumableStream({
@ -317,7 +315,7 @@ describe('executeResumableStream', () => {
});
it('passes resume options from the control hook', async () => {
const resume = jest.fn().mockResolvedValue({
const resume = vi.fn().mockResolvedValue({
runId: 'agent-run-2',
stream: readableFromChunks([textChunk('Done.')]),
});
@ -365,11 +363,11 @@ describe('executeResumableStream', () => {
const finishGate = createDeferred<undefined>();
const approval = createDeferred<Record<string, unknown>>();
const waitStarted = createDeferred<undefined>();
const resume = jest.fn().mockResolvedValue({
const resume = vi.fn().mockResolvedValue({
runId: 'agent-run-2',
stream: readableFromChunks([textChunk('Done.')]),
});
const waitForConfirmation = jest.fn().mockImplementation(async () => {
const waitForConfirmation = vi.fn().mockImplementation(async () => {
waitStarted.resolve(undefined);
return await approval.promise;
});
@ -429,12 +427,12 @@ describe('executeResumableStream', () => {
it('surfaces only the first actionable suspension in a drain', async () => {
const eventBus = createEventBus();
const resume = jest.fn().mockResolvedValue({
const resume = vi.fn().mockResolvedValue({
runId: 'agent-run-2',
stream: readableFromChunks([textChunk('Done.')]),
});
const waitForConfirmation = jest.fn().mockResolvedValue({ approved: true });
const onSuspension = jest.fn((_: SuspensionInfo) => undefined);
const waitForConfirmation = vi.fn().mockResolvedValue({ approved: true });
const onSuspension = vi.fn((_: SuspensionInfo) => undefined);
await executeResumableStream({
agent: { resume },

View File

@ -10,11 +10,11 @@ import type {
} from '../run-state-registry';
import { RunStateRegistry } from '../run-state-registry';
jest.mock('nanoid', () => ({
nanoid: jest.fn(),
vi.mock('nanoid', () => ({
nanoid: vi.fn(),
}));
const mockedNanoid = jest.mocked(nanoid);
const mockedNanoid = vi.mocked(nanoid);
const day = 24 * 60 * 60_000;
interface TestUser {
@ -677,7 +677,7 @@ describe('RunStateRegistry', () => {
describe('confirmation flow', () => {
describe('registerPendingConfirmation + resolvePendingConfirmation', () => {
it('resolves pending confirmation with matching userId', () => {
const resolve = jest.fn();
const resolve = vi.fn();
const pending: PendingConfirmation = {
resolve,
threadId: 'thread-1',
@ -695,7 +695,7 @@ describe('RunStateRegistry', () => {
});
it('removes the confirmation after resolving', () => {
const resolve = jest.fn();
const resolve = vi.fn();
const pending: PendingConfirmation = {
resolve,
threadId: 'thread-1',
@ -715,7 +715,7 @@ describe('RunStateRegistry', () => {
});
it('returns false when userId does not match', () => {
const resolve = jest.fn();
const resolve = vi.fn();
const pending: PendingConfirmation = {
resolve,
threadId: 'thread-1',
@ -743,7 +743,7 @@ describe('RunStateRegistry', () => {
describe('pending confirmation queries', () => {
it('returns pending confirmation metadata without consuming it', () => {
const resolve = jest.fn();
const resolve = vi.fn();
const pending: PendingConfirmation = {
resolve,
threadId: 'thread-1',
@ -771,7 +771,7 @@ describe('RunStateRegistry', () => {
describe('rejectPendingConfirmation', () => {
it('auto-rejects with { approved: false }', () => {
const resolve = jest.fn();
const resolve = vi.fn();
const pending: PendingConfirmation = {
resolve,
threadId: 'thread-1',
@ -788,7 +788,7 @@ describe('RunStateRegistry', () => {
});
it('removes the confirmation after rejecting', () => {
const resolve = jest.fn();
const resolve = vi.fn();
registry.registerPendingConfirmation('req-1', {
resolve,
threadId: 'thread-1',
@ -819,7 +819,7 @@ describe('RunStateRegistry', () => {
// Re-add an active run (to test both active and suspended)
registry.startRun({ threadId: 'thread-1', user: { id: 'u1', name: 'A' } });
const resolve = jest.fn();
const resolve = vi.fn();
registry.registerPendingConfirmation('req-1', {
resolve,
threadId: 'thread-1',
@ -835,7 +835,7 @@ describe('RunStateRegistry', () => {
});
it('uses custom cancellation data when provided', () => {
const resolve = jest.fn();
const resolve = vi.fn();
registry.registerPendingConfirmation('req-1', {
resolve,
threadId: 'thread-1',
@ -866,8 +866,8 @@ describe('RunStateRegistry', () => {
});
it('only resolves confirmations belonging to the target thread', () => {
const resolveThread1 = jest.fn();
const resolveThread2 = jest.fn();
const resolveThread1 = vi.fn();
const resolveThread2 = vi.fn();
registry.registerPendingConfirmation('req-1', {
resolve: resolveThread1,
@ -898,7 +898,7 @@ describe('RunStateRegistry', () => {
user: { id: 'u1', name: 'Alice' },
});
const resolve = jest.fn();
const resolve = vi.fn();
registry.registerPendingConfirmation('req-1', {
resolve,
threadId: 'thread-1',
@ -967,8 +967,8 @@ describe('RunStateRegistry', () => {
// tool to run to completion as "denied" and mutate the snapshot
// mid-shutdown; we intentionally let them dangle so the user sees
// the original confirmation card on reload.
const resolve1 = jest.fn();
const resolve2 = jest.fn();
const resolve1 = vi.fn();
const resolve2 = vi.fn();
registry.registerPendingConfirmation('req-1', {
resolve: resolve1,
@ -992,13 +992,13 @@ describe('RunStateRegistry', () => {
it('deduplicates pendingThreadIds when one thread has multiple confirmations', () => {
registry.registerPendingConfirmation('req-1', {
resolve: jest.fn(),
resolve: vi.fn(),
threadId: 'thread-shared',
userId: 'user-1',
createdAt: Date.now(),
});
registry.registerPendingConfirmation('req-2', {
resolve: jest.fn(),
resolve: vi.fn(),
threadId: 'thread-shared',
userId: 'user-1',
createdAt: Date.now(),
@ -1015,7 +1015,7 @@ describe('RunStateRegistry', () => {
user: { id: 'u1', name: 'A' },
});
registry.registerPendingConfirmation('req-1', {
resolve: jest.fn(),
resolve: vi.fn(),
threadId: 'thread-1',
userId: 'user-1',
createdAt: Date.now(),
@ -1075,14 +1075,14 @@ describe('RunStateRegistry', () => {
const now = Date.now();
registry.registerPendingConfirmation('req-old', {
resolve: jest.fn(),
resolve: vi.fn(),
threadId: 'thread-1',
userId: 'user-1',
createdAt: now - 60_000,
});
registry.registerPendingConfirmation('req-new', {
resolve: jest.fn(),
resolve: vi.fn(),
threadId: 'thread-2',
userId: 'user-2',
createdAt: now - 10_000,
@ -1105,7 +1105,7 @@ describe('RunStateRegistry', () => {
registry.startRun({ threadId: 'thread-1', user: { id: 'u1', name: 'A' } });
registry.touchActiveRun('thread-1', 0);
registry.registerPendingConfirmation('req-1', {
resolve: jest.fn(),
resolve: vi.fn(),
threadId: 'thread-1',
userId: 'user-1',
createdAt: 20_000,
@ -1126,7 +1126,7 @@ describe('RunStateRegistry', () => {
);
registry.registerPendingConfirmation('req-1', {
resolve: jest.fn(),
resolve: vi.fn(),
threadId: 'thread-1',
userId: 'user-1',
createdAt: now - 60_000,

View File

@ -3,29 +3,31 @@ import type * as ResumableStreamExecutor from '../resumable-stream-executor';
import { executeResumableStream } from '../resumable-stream-executor';
import { streamAgentRun } from '../stream-runner';
jest.mock('../resumable-stream-executor', () => {
const actual = jest.requireActual<typeof ResumableStreamExecutor>('../resumable-stream-executor');
vi.mock('../resumable-stream-executor', async () => {
const actual = await vi.importActual<typeof ResumableStreamExecutor>(
'../resumable-stream-executor',
);
return {
...actual,
executeResumableStream: jest.fn(),
executeResumableStream: vi.fn(),
};
});
const emptyWorkSummary: WorkSummary = { toolCalls: [], totalToolCalls: 0, totalToolErrors: 0 };
function createLogger() {
return { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
}
function createEventBus() {
return {
publish: jest.fn(),
subscribe: jest.fn(),
getEventsAfter: jest.fn(),
getNextEventId: jest.fn(),
getEventsForRun: jest.fn().mockReturnValue([]),
getEventsForRuns: jest.fn().mockReturnValue([]),
publish: vi.fn(),
subscribe: vi.fn(),
getEventsAfter: vi.fn(),
getNextEventId: vi.fn(),
getEventsForRun: vi.fn().mockReturnValue([]),
getEventsForRuns: vi.fn().mockReturnValue([]),
};
}
@ -51,14 +53,14 @@ async function collectAsyncIterable(stream: AsyncIterable<unknown>) {
describe('streamAgentRun', () => {
it('returns errored status when agent stream contains an error chunk', async () => {
jest.mocked(executeResumableStream).mockResolvedValue({
vi.mocked(executeResumableStream).mockResolvedValue({
status: 'errored',
agentRunId: 'agent-run-1',
workSummary: emptyWorkSummary,
});
const eventBus = createEventBus();
const agent = {
stream: jest.fn().mockResolvedValue({
stream: vi.fn().mockResolvedValue({
runId: 'agent-run-1',
fullStream: fromChunks([
{ type: 'text-delta', delta: 'Hello' },
@ -87,14 +89,14 @@ describe('streamAgentRun', () => {
});
it('returns completed status for successful streams', async () => {
jest.mocked(executeResumableStream).mockResolvedValue({
vi.mocked(executeResumableStream).mockResolvedValue({
status: 'completed',
agentRunId: 'agent-run-1',
workSummary: emptyWorkSummary,
});
const eventBus = createEventBus();
const agent = {
stream: jest.fn().mockResolvedValue({
stream: vi.fn().mockResolvedValue({
runId: 'agent-run-1',
fullStream: fromChunks([{ type: 'text-delta', delta: 'All good' }]),
}),
@ -119,9 +121,9 @@ describe('streamAgentRun', () => {
});
it('passes through the buffered manual confirmation event', async () => {
const mockedExecuteResumableStream = jest.mocked(executeResumableStream);
const mockedExecuteResumableStream = vi.mocked(executeResumableStream);
const agent = {
stream: jest.fn().mockResolvedValue({
stream: vi.fn().mockResolvedValue({
runId: 'agent-run-1',
fullStream: emptyStream(),
}),
@ -181,7 +183,7 @@ describe('streamAgentRun', () => {
});
it('passes an already-normalized native stream source through to the resumable executor', async () => {
const mockedExecuteResumableStream = jest.mocked(executeResumableStream);
const mockedExecuteResumableStream = vi.mocked(executeResumableStream);
const streamResult = {
runId: 'agent-run-2',
fullStream: emptyStream(),
@ -191,7 +193,7 @@ describe('streamAgentRun', () => {
usage: Promise.resolve({ inputTokens: 1, outputTokens: 2, totalTokens: 3 }),
};
const agent = {
stream: jest.fn().mockResolvedValue(streamResult),
stream: vi.fn().mockResolvedValue(streamResult),
};
const eventBus = createEventBus();
@ -224,7 +226,7 @@ describe('streamAgentRun', () => {
});
it('normalizes native agent readable streams for the resumable executor', async () => {
const mockedExecuteResumableStream = jest.mocked(executeResumableStream);
const mockedExecuteResumableStream = vi.mocked(executeResumableStream);
mockedExecuteResumableStream.mockClear();
const nativeChunk = { type: 'text-delta', delta: 'All good' };
const readable = new ReadableStream<unknown>({
@ -234,10 +236,10 @@ describe('streamAgentRun', () => {
},
});
const agent = {
stream: jest.fn().mockResolvedValue({
stream: vi.fn().mockResolvedValue({
runId: 'agent-run-1',
stream: readable,
getState: jest.fn(),
getState: vi.fn(),
}),
};
const eventBus = createEventBus();

View File

@ -7,6 +7,7 @@ import {
type WorkspaceSandbox,
} from '@n8n/agents';
import { jsonParse } from 'n8n-workflow';
import type { Mock } from 'vitest';
import {
N8N_SKILLS_DIR_ENV,
@ -23,18 +24,19 @@ import { loadInstanceAiRuntimeSkillSource } from '../runtime-skills';
function createMockWorkspace() {
const writes = new Map<string, string>();
const writeFile = jest.fn(async (path: string, content: string | Buffer) => {
const writeFile = vi.fn(async (path: string, content: string | Buffer) => {
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
await Promise.resolve();
});
const readFile = jest.fn(async (path: string) => {
const readFile = vi.fn(async (path: string) => {
const content = writes.get(path);
if (content === undefined) throw new Error(`ENOENT: ${path}`);
return await Promise.resolve(content);
});
const executeCommand = jest.fn<
ReturnType<NonNullable<WorkspaceSandbox['executeCommand']>>,
Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>
const executeCommand = vi.fn<
(
...args: Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>
) => ReturnType<NonNullable<WorkspaceSandbox['executeCommand']>>
>(
async () =>
await Promise.resolve({
@ -374,7 +376,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
}),
};
const { workspace } = createMockWorkspace();
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
await materializeRuntimeSkillsIntoWorkspace({
source,
@ -383,9 +385,8 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
logger,
});
const warnMock = logger.warn as jest.Mock<
void,
[string, { skill?: unknown; bytes?: unknown; maxBytes?: unknown }?]
const warnMock = logger.warn as Mock<
(message: string, meta?: { skill?: unknown; bytes?: unknown; maxBytes?: unknown }) => void
>;
const limitWarnCall = warnMock.mock.calls.find(
(call) => call[0] === 'Runtime skill file exceeds load_skill output limit',

View File

@ -10,7 +10,7 @@ const EMPTY_SOURCE_FILE = '\n';
function loadSourceMapSupport(): SourceMapSupportModule | undefined {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('source-map-support') as SourceMapSupportModule;
return mod;
} catch {

View File

@ -1,25 +1,25 @@
import type { Mock } from 'vitest';
import type { PlannedTaskGraph } from '../../types';
import { PlannedTaskStorage } from '../planned-task-storage';
import { patchThread, type PatchableThreadMemory } from '../thread-patch';
import type * as ThreadPatch from '../thread-patch';
jest.mock('../thread-patch', () => {
const actual =
// eslint-disable-next-line @typescript-eslint/no-require-imports
jest.requireActual<typeof ThreadPatch>('../thread-patch');
vi.mock('../thread-patch', async () => {
const actual = await vi.importActual<typeof ThreadPatch>('../thread-patch');
return {
...actual,
patchThread: jest.fn(),
patchThread: vi.fn(),
};
});
const mockedPatchThread = jest.mocked(patchThread);
type TestMemory = PatchableThreadMemory & { getThread: jest.Mock };
const mockedPatchThread = vi.mocked(patchThread);
type TestMemory = PatchableThreadMemory & { getThread: Mock };
function makeMemory(): TestMemory {
return {
getThread: jest.fn(),
getThread: vi.fn(),
};
}
@ -54,7 +54,7 @@ describe('PlannedTaskStorage', () => {
let storage: PlannedTaskStorage;
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
memory = makeMemory();
storage = new PlannedTaskStorage(memory);
});

View File

@ -1,13 +1,13 @@
jest.mock('../thread-patch', () => ({
getThread: jest.fn(),
patchThread: jest.fn(),
vi.mock('../thread-patch', () => ({
getThread: vi.fn(),
patchThread: vi.fn(),
}));
import { TerminalOutcomeStorage, type TerminalOutcome } from '../terminal-outcome-storage';
import { getThread, patchThread, type PatchableThreadMemory } from '../thread-patch';
const mockedGetThread = jest.mocked(getThread);
const mockedPatchThread = jest.mocked(patchThread);
const mockedGetThread = vi.mocked(getThread);
const mockedPatchThread = vi.mocked(patchThread);
function makeMemory(): PatchableThreadMemory {
return {};
@ -43,7 +43,7 @@ describe('TerminalOutcomeStorage', () => {
let storage: TerminalOutcomeStorage;
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
memory = makeMemory();
storage = new TerminalOutcomeStorage(memory);
});

View File

@ -1,25 +1,25 @@
import type { Mock } from 'vitest';
import type { IterationEntry } from '../iteration-log';
import { ThreadIterationLogStorage } from '../thread-iteration-log-storage';
import { patchThread, type PatchableThreadMemory } from '../thread-patch';
import type * as ThreadPatch from '../thread-patch';
jest.mock('../thread-patch', () => {
const actual =
// eslint-disable-next-line @typescript-eslint/no-require-imports
jest.requireActual<typeof ThreadPatch>('../thread-patch');
vi.mock('../thread-patch', async () => {
const actual = await vi.importActual<typeof ThreadPatch>('../thread-patch');
return {
...actual,
patchThread: jest.fn(),
patchThread: vi.fn(),
};
});
const mockedPatchThread = jest.mocked(patchThread);
type TestMemory = PatchableThreadMemory & { getThread: jest.Mock };
const mockedPatchThread = vi.mocked(patchThread);
type TestMemory = PatchableThreadMemory & { getThread: Mock };
function makeMemory(): TestMemory {
return {
getThread: jest.fn(),
getThread: vi.fn(),
};
}
@ -37,7 +37,7 @@ describe('ThreadIterationLogStorage', () => {
let storage: ThreadIterationLogStorage;
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
memory = makeMemory();
storage = new ThreadIterationLogStorage(memory);
});
@ -99,7 +99,7 @@ describe('ThreadIterationLogStorage', () => {
describe('getForTask', () => {
it('returns entries for a specific task key', async () => {
const entry = makeEntry();
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
id: 'thread-1',
title: 'Test',
metadata: {
@ -115,7 +115,7 @@ describe('ThreadIterationLogStorage', () => {
});
it('returns empty array when thread has no log', async () => {
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
id: 'thread-1',
title: 'Test',
metadata: {},
@ -129,14 +129,14 @@ describe('ThreadIterationLogStorage', () => {
});
it('returns empty array when thread not found', async () => {
jest.mocked(memory.getThread).mockResolvedValue(null);
vi.mocked(memory.getThread).mockResolvedValue(null);
const result = await storage.getForTask('unknown', 'task-key');
expect(result).toEqual([]);
});
it('returns empty array when task key does not exist', async () => {
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
id: 'thread-1',
title: 'Test',
metadata: {

View File

@ -1,3 +1,5 @@
import type { Mock } from 'vitest';
import {
getThread,
patchThread,
@ -15,23 +17,23 @@ const baseThread: ThreadRecord = {
};
type TestMemory = PatchableThreadMemory & {
getThread: jest.Mock;
saveThread: jest.Mock;
getThread: Mock;
saveThread: Mock;
};
function makeMemory(overrides: Partial<TestMemory> = {}): TestMemory {
return {
...overrides,
getThread: overrides.getThread ?? jest.fn().mockResolvedValue({ ...baseThread }),
getThread: overrides.getThread ?? vi.fn().mockResolvedValue({ ...baseThread }),
saveThread:
overrides.saveThread ?? jest.fn().mockImplementation((thread: ThreadRecord) => thread),
overrides.saveThread ?? vi.fn().mockImplementation((thread: ThreadRecord) => thread),
};
}
describe('getThread', () => {
it('uses native getThread when available', async () => {
const memory = makeMemory({
getThread: jest.fn().mockResolvedValue({ ...baseThread, title: 'Native' }),
getThread: vi.fn().mockResolvedValue({ ...baseThread, title: 'Native' }),
});
const result = await getThread(memory, 'thread-1');
@ -50,9 +52,9 @@ describe('getThread', () => {
describe('patchThread', () => {
describe('when memory has patchThread method', () => {
it('calls memory.patchThread directly', async () => {
const patchFn = jest.fn().mockResolvedValue({ ...baseThread, title: 'Patched' });
const patchFn = vi.fn().mockResolvedValue({ ...baseThread, title: 'Patched' });
const memory = makeMemory({ patchThread: patchFn });
const update = jest.fn().mockReturnValue({ title: 'Patched' });
const update = vi.fn().mockReturnValue({ title: 'Patched' });
const result = await patchThread(memory, { threadId: 'thread-1', update });
@ -64,10 +66,10 @@ describe('patchThread', () => {
describe('native getThread + saveThread fallback', () => {
it('reads thread, calls update, then saves', async () => {
const memory = makeMemory({
getThread: jest.fn().mockResolvedValue({ ...baseThread }),
saveThread: jest.fn(),
getThread: vi.fn().mockResolvedValue({ ...baseThread }),
saveThread: vi.fn(),
});
const update = jest.fn().mockReturnValue({ title: 'Updated Title' });
const update = vi.fn().mockReturnValue({ title: 'Updated Title' });
const result = await patchThread(memory, { threadId: 'thread-1', update });
@ -87,7 +89,7 @@ describe('patchThread', () => {
it('returns unchanged thread when update returns null', async () => {
const memory = makeMemory();
const update = jest.fn().mockReturnValue(null);
const update = vi.fn().mockReturnValue(null);
const result = await patchThread(memory, { threadId: 'thread-1', update });
@ -97,9 +99,9 @@ describe('patchThread', () => {
it('returns null when thread does not exist', async () => {
const memory = makeMemory({
getThread: jest.fn().mockResolvedValue(null),
getThread: vi.fn().mockResolvedValue(null),
});
const update = jest.fn();
const update = vi.fn();
const result = await patchThread(memory, { threadId: 'unknown', update });
@ -109,12 +111,12 @@ describe('patchThread', () => {
it('uses threadId as title fallback when thread has no title', async () => {
const memory = makeMemory({
getThread: jest.fn().mockResolvedValue({
getThread: vi.fn().mockResolvedValue({
...baseThread,
title: undefined,
}),
});
const update = jest.fn().mockReturnValue({ metadata: { newKey: 'newVal' } });
const update = vi.fn().mockReturnValue({ metadata: { newKey: 'newVal' } });
await patchThread(memory, { threadId: 'thread-1', update });
@ -125,7 +127,7 @@ describe('patchThread', () => {
it('applies metadata from patch when provided', async () => {
const memory = makeMemory();
const update = jest.fn().mockReturnValue({ metadata: { newKey: 'newVal' } });
const update = vi.fn().mockReturnValue({ metadata: { newKey: 'newVal' } });
await patchThread(memory, { threadId: 'thread-1', update });
@ -136,7 +138,7 @@ describe('patchThread', () => {
it('passes a defensive copy of metadata to update function', async () => {
const memory = makeMemory();
const update = jest.fn().mockImplementation((thread: ThreadRecord) => {
const update = vi.fn().mockImplementation((thread: ThreadRecord) => {
thread.metadata = { ...(thread.metadata ?? {}), mutated: true };
return { title: 'Updated' };
});

View File

@ -1,27 +1,26 @@
import type { TaskList } from '@n8n/api-types';
import type { Mock } from 'vitest';
import type { PatchableThreadMemory } from '../thread-patch';
import type * as ThreadPatch from '../thread-patch';
import { patchThread } from '../thread-patch';
import { ThreadTaskStorage } from '../thread-task-storage';
jest.mock('../thread-patch', () => {
const actual =
// eslint-disable-next-line @typescript-eslint/no-require-imports
jest.requireActual<typeof ThreadPatch>('../thread-patch');
vi.mock('../thread-patch', async () => {
const actual = await vi.importActual<typeof ThreadPatch>('../thread-patch');
return {
...actual,
patchThread: jest.fn(),
patchThread: vi.fn(),
};
});
const mockedPatchThread = jest.mocked(patchThread);
type TestMemory = PatchableThreadMemory & { getThread: jest.Mock };
const mockedPatchThread = vi.mocked(patchThread);
type TestMemory = PatchableThreadMemory & { getThread: Mock };
function makeMemory(): TestMemory {
return {
getThread: jest.fn(),
getThread: vi.fn(),
};
}
@ -40,14 +39,14 @@ describe('ThreadTaskStorage', () => {
let storage: ThreadTaskStorage;
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
memory = makeMemory();
storage = new ThreadTaskStorage(memory);
});
describe('get', () => {
it('returns task list from thread metadata', async () => {
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
id: 'thread-1',
title: 'Test',
metadata: { instanceAiTasks: sampleTaskList },
@ -61,7 +60,7 @@ describe('ThreadTaskStorage', () => {
});
it('returns null when no tasks in metadata', async () => {
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
id: 'thread-1',
title: 'Test',
metadata: {},
@ -74,13 +73,13 @@ describe('ThreadTaskStorage', () => {
});
it('returns null when thread not found', async () => {
jest.mocked(memory.getThread).mockResolvedValue(null);
vi.mocked(memory.getThread).mockResolvedValue(null);
expect(await storage.get('unknown')).toBeNull();
});
it('returns null when metadata fails Zod validation', async () => {
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
id: 'thread-1',
title: 'Test',
metadata: { instanceAiTasks: 'invalid-data' },

View File

@ -1,25 +1,25 @@
import type { Mock } from 'vitest';
import type { WorkflowLoopState, AttemptRecord } from '../../workflow-loop/workflow-loop-state';
import { patchThread, type PatchableThreadMemory } from '../thread-patch';
import type * as ThreadPatch from '../thread-patch';
import { WorkflowLoopStorage } from '../workflow-loop-storage';
jest.mock('../thread-patch', () => {
const actual =
// eslint-disable-next-line @typescript-eslint/no-require-imports
jest.requireActual<typeof ThreadPatch>('../thread-patch');
vi.mock('../thread-patch', async () => {
const actual = await vi.importActual<typeof ThreadPatch>('../thread-patch');
return {
...actual,
patchThread: jest.fn(),
patchThread: vi.fn(),
};
});
const mockedPatchThread = jest.mocked(patchThread);
type TestMemory = PatchableThreadMemory & { getThread: jest.Mock };
const mockedPatchThread = vi.mocked(patchThread);
type TestMemory = PatchableThreadMemory & { getThread: Mock };
function makeMemory(): TestMemory {
return {
getThread: jest.fn(),
getThread: vi.fn(),
};
}
@ -60,7 +60,7 @@ describe('WorkflowLoopStorage', () => {
let storage: WorkflowLoopStorage;
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
memory = makeMemory();
storage = new WorkflowLoopStorage(memory);
});
@ -69,7 +69,7 @@ describe('WorkflowLoopStorage', () => {
it('returns work item from thread metadata', async () => {
const state = makeState();
const attempts = [makeAttempt()];
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
...baseThread,
metadata: {
instanceAiWorkflowLoop: {
@ -84,7 +84,7 @@ describe('WorkflowLoopStorage', () => {
});
it('returns null for unknown work item', async () => {
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
...baseThread,
metadata: {
instanceAiWorkflowLoop: {},
@ -95,7 +95,7 @@ describe('WorkflowLoopStorage', () => {
});
it('returns null when no loop metadata exists', async () => {
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
...baseThread,
metadata: {},
});
@ -152,7 +152,7 @@ describe('WorkflowLoopStorage', () => {
const activeState = makeState({ workItemId: 'wi-active', status: 'active' });
const doneState = makeState({ workItemId: 'wi-done', status: 'completed' });
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
...baseThread,
metadata: {
instanceAiWorkflowLoop: {
@ -169,7 +169,7 @@ describe('WorkflowLoopStorage', () => {
it('returns null when no active work item exists', async () => {
const doneState = makeState({ workItemId: 'wi-done', status: 'completed' });
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
...baseThread,
metadata: {
instanceAiWorkflowLoop: {
@ -182,7 +182,7 @@ describe('WorkflowLoopStorage', () => {
});
it('returns null when no loop metadata', async () => {
jest.mocked(memory.getThread).mockResolvedValue({
vi.mocked(memory.getThread).mockResolvedValue({
...baseThread,
metadata: {},
});

View File

@ -10,17 +10,17 @@ async function* fromChunks(chunks: unknown[]) {
function createEventBus(): InstanceAiEventBus {
return {
publish: jest.fn(),
subscribe: jest.fn().mockReturnValue(() => {}),
getEventsAfter: jest.fn(),
getNextEventId: jest.fn(),
getEventsForRun: jest.fn().mockReturnValue([]),
getEventsForRuns: jest.fn().mockReturnValue([]),
publish: vi.fn(),
subscribe: vi.fn().mockReturnValue(() => {}),
getEventsAfter: vi.fn(),
getNextEventId: vi.fn(),
getEventsForRun: vi.fn().mockReturnValue([]),
getEventsForRuns: vi.fn().mockReturnValue([]),
};
}
function createLogger() {
return { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
}
describe('consumeStreamWithHitl', () => {
@ -38,7 +38,7 @@ describe('consumeStreamWithHitl', () => {
logger: createLogger(),
threadId: 'thread-1',
abortSignal: new AbortController().signal,
waitForConfirmation: jest.fn(),
waitForConfirmation: vi.fn(),
});
expect(result.status).toBe('errored');
@ -62,7 +62,7 @@ describe('consumeStreamWithHitl', () => {
logger: createLogger(),
threadId: 'thread-1',
abortSignal: new AbortController().signal,
waitForConfirmation: jest.fn(),
waitForConfirmation: vi.fn(),
});
await expect(requireCompletedHitlText(result, 'Test sub-agent')).resolves.toBe('done');

View File

@ -1,4 +1,5 @@
import type { InstanceAiPermissions } from '@n8n/api-types';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext, CredentialSummary, CredentialDetail } from '../../types';
@ -18,13 +19,13 @@ function createMockContext(
nodeService: {} as InstanceAiContext['nodeService'],
dataTableService: {} as InstanceAiContext['dataTableService'],
credentialService: {
list: jest.fn().mockResolvedValue([]),
get: jest.fn().mockResolvedValue({}),
delete: jest.fn().mockResolvedValue(undefined),
test: jest.fn().mockResolvedValue({ success: true }),
searchCredentialTypes: jest.fn().mockResolvedValue([]),
getDocumentationUrl: jest.fn().mockResolvedValue(null),
getCredentialFields: jest.fn().mockResolvedValue([]),
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
test: vi.fn().mockResolvedValue({ success: true }),
searchCredentialTypes: vi.fn().mockResolvedValue([]),
getDocumentationUrl: vi.fn().mockResolvedValue(null),
getCredentialFields: vi.fn().mockResolvedValue([]),
},
permissions: {},
...overrides,
@ -35,7 +36,7 @@ function noSuspendCtx() {
return { resumeData: undefined, suspend: undefined } as never;
}
function suspendCtx(suspendFn: jest.Mock = jest.fn()) {
function suspendCtx(suspendFn: Mock = vi.fn()) {
return {
resumeData: undefined,
suspend: suspendFn,
@ -47,7 +48,7 @@ function resumeCtx(resumeData: {
credentials?: Record<string, string>;
autoSetup?: { credentialType: string };
}) {
const suspend = jest.fn();
const suspend = vi.fn();
return { resumeData, suspend } as never;
}
@ -152,7 +153,7 @@ describe('credentials tool', () => {
{ id: '3', name: 'Notion Key', type: 'notionApi' },
];
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
@ -172,7 +173,7 @@ describe('credentials tool', () => {
it('should filter by type when provided', async () => {
const credentials: CredentialSummary[] = [{ id: '1', name: 'Slack Token', type: 'slackApi' }];
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
await executeTool(tool, { action: 'list' as const, type: 'slackApi' }, noSuspendCtx());
@ -187,7 +188,7 @@ describe('credentials tool', () => {
type: 'testType',
}));
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
const result = await executeTool(
@ -214,7 +215,7 @@ describe('credentials tool', () => {
type: 'testType',
}));
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
@ -230,7 +231,7 @@ describe('credentials tool', () => {
{ id: '3', name: 'Notion Key', type: 'notionApi' },
];
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
const result = await executeTool(
@ -256,7 +257,7 @@ describe('credentials tool', () => {
type: 'notionApi',
}));
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
const result = await executeTool(
@ -279,7 +280,7 @@ describe('credentials tool', () => {
type: 'testType',
}));
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
@ -299,7 +300,7 @@ describe('credentials tool', () => {
type: 'slackApi',
}));
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
const result = await executeTool(
@ -317,7 +318,7 @@ describe('credentials tool', () => {
{ id: '1', name: 'Slack Token', type: 'slackApi', extraField: 'should-be-stripped' },
];
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
(context.credentialService.list as Mock).mockResolvedValue(credentials);
const tool = createCredentialsTool(context);
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
@ -339,7 +340,7 @@ describe('credentials tool', () => {
nodesWithAccess: [{ nodeType: 'n8n-nodes-base.notion' }],
};
const context = createMockContext();
(context.credentialService.get as jest.Mock).mockResolvedValue(detail);
(context.credentialService.get as Mock).mockResolvedValue(detail);
const tool = createCredentialsTool(context);
const result = await executeTool(
@ -396,7 +397,7 @@ describe('credentials tool', () => {
const context = createMockContext({
permissions: { deleteCredential: 'require_approval' },
});
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
@ -420,7 +421,7 @@ describe('credentials tool', () => {
const context = createMockContext({
permissions: { deleteCredential: 'require_approval' },
});
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
@ -439,7 +440,7 @@ describe('credentials tool', () => {
it('should suspend by default when permissions are not explicitly set', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
@ -498,9 +499,7 @@ describe('credentials tool', () => {
{ type: 'slackOAuth2Api', displayName: 'Slack OAuth2 API' },
];
const context = createMockContext();
(context.credentialService.searchCredentialTypes as jest.Mock).mockResolvedValue(
searchResults,
);
(context.credentialService.searchCredentialTypes as Mock).mockResolvedValue(searchResults);
const tool = createCredentialsTool(context);
const result = await executeTool(
@ -526,9 +525,7 @@ describe('credentials tool', () => {
{ type: 'oAuth2Api', displayName: 'OAuth2' },
];
const context = createMockContext();
(context.credentialService.searchCredentialTypes as jest.Mock).mockResolvedValue(
searchResults,
);
(context.credentialService.searchCredentialTypes as Mock).mockResolvedValue(searchResults);
const tool = createCredentialsTool(context);
const result = await executeTool(
@ -565,9 +562,9 @@ describe('credentials tool', () => {
{ id: 'c1', name: 'Existing Slack', type: 'slackApi' },
];
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue(existingCreds);
(context.credentialService.list as Mock).mockResolvedValue(existingCreds);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
tool,
@ -598,9 +595,9 @@ describe('credentials tool', () => {
it('should include suggestedName in credentialRequests when provided', async () => {
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
tool,
@ -631,9 +628,9 @@ describe('credentials tool', () => {
it('should use plural message for multiple credentials', async () => {
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
tool,
@ -654,9 +651,9 @@ describe('credentials tool', () => {
it('should include projectId in suspend payload when provided', async () => {
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
tool,
@ -674,7 +671,7 @@ describe('credentials tool', () => {
it('should scope credential lookup to projectId when provided', async () => {
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const tool = createCredentialsTool(context);
await tool.handler!(
@ -683,7 +680,7 @@ describe('credentials tool', () => {
credentials: [{ credentialType: 'slackApi' }],
projectId: 'proj-1',
},
suspendCtx(jest.fn()),
suspendCtx(vi.fn()),
);
expect(context.credentialService.list).toHaveBeenCalledWith({
@ -694,7 +691,7 @@ describe('credentials tool', () => {
it('should omit projectId from credential lookup when not provided', async () => {
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const tool = createCredentialsTool(context);
await tool.handler!(
@ -702,7 +699,7 @@ describe('credentials tool', () => {
action: 'setup' as const,
credentials: [{ credentialType: 'slackApi' }],
},
suspendCtx(jest.fn()),
suspendCtx(vi.fn()),
);
expect(context.credentialService.list).toHaveBeenCalledWith({ type: 'slackApi' });
@ -710,9 +707,9 @@ describe('credentials tool', () => {
it('should include credentialFlow in suspend payload for finalize stage', async () => {
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
tool,
@ -774,10 +771,10 @@ describe('credentials tool', () => {
it('should return needsBrowserSetup when autoSetup is present', async () => {
const context = createMockContext();
(context.credentialService.getDocumentationUrl as jest.Mock).mockResolvedValue(
(context.credentialService.getDocumentationUrl as Mock).mockResolvedValue(
'https://docs.example.com/slack',
);
(context.credentialService.getCredentialFields as jest.Mock).mockResolvedValue([
(context.credentialService.getCredentialFields as Mock).mockResolvedValue([
{ name: 'apiKey', displayName: 'API Key', type: 'string', required: true },
]);
@ -828,7 +825,7 @@ describe('credentials tool', () => {
it('should return missing_credentials error when credentials is undefined', async () => {
const context = createMockContext();
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
const result = await tool.handler!(
@ -846,7 +843,7 @@ describe('credentials tool', () => {
it('should return missing_credentials error when credentials is an empty array', async () => {
const context = createMockContext();
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
const result = await tool.handler!(
@ -863,9 +860,9 @@ describe('credentials tool', () => {
it('should default reason when not provided in credential requests', async () => {
const context = createMockContext();
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
tool,
@ -894,7 +891,7 @@ describe('credentials tool', () => {
describe('test action', () => {
it('should call credentialService.test and return result', async () => {
const context = createMockContext();
(context.credentialService.test as jest.Mock).mockResolvedValue({
(context.credentialService.test as Mock).mockResolvedValue({
success: true,
message: 'Connection successful',
});
@ -912,9 +909,7 @@ describe('credentials tool', () => {
it('should handle errors from credentialService.test', async () => {
const context = createMockContext();
(context.credentialService.test as jest.Mock).mockRejectedValue(
new Error('Connection refused'),
);
(context.credentialService.test as Mock).mockRejectedValue(new Error('Connection refused'));
const tool = createCredentialsTool(context);
const result = await executeTool(
@ -931,7 +926,7 @@ describe('credentials tool', () => {
it('should handle non-Error throws from credentialService.test', async () => {
const context = createMockContext();
(context.credentialService.test as jest.Mock).mockRejectedValue('string error');
(context.credentialService.test as Mock).mockRejectedValue('string error');
const tool = createCredentialsTool(context);
const result = await executeTool(

View File

@ -1,4 +1,5 @@
import type { InstanceAiPermissions } from '@n8n/api-types';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../types';
@ -18,24 +19,24 @@ function createMockContext(
nodeService: {} as InstanceAiContext['nodeService'],
credentialService: {} as InstanceAiContext['credentialService'],
dataTableService: {
list: jest.fn().mockResolvedValue([]),
getSchema: jest.fn().mockResolvedValue([]),
queryRows: jest.fn().mockResolvedValue({ count: 0, data: [] }),
create: jest.fn().mockResolvedValue({}),
delete: jest.fn().mockResolvedValue(undefined),
addColumn: jest.fn().mockResolvedValue({}),
deleteColumn: jest.fn().mockResolvedValue(undefined),
renameColumn: jest.fn().mockResolvedValue(undefined),
insertRows: jest.fn().mockResolvedValue({ insertedCount: 0 }),
updateRows: jest.fn().mockResolvedValue({ updatedCount: 0 }),
deleteRows: jest.fn().mockResolvedValue({ deletedCount: 0 }),
list: vi.fn().mockResolvedValue([]),
getSchema: vi.fn().mockResolvedValue([]),
queryRows: vi.fn().mockResolvedValue({ count: 0, data: [] }),
create: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
addColumn: vi.fn().mockResolvedValue({}),
deleteColumn: vi.fn().mockResolvedValue(undefined),
renameColumn: vi.fn().mockResolvedValue(undefined),
insertRows: vi.fn().mockResolvedValue({ insertedCount: 0 }),
updateRows: vi.fn().mockResolvedValue({ updatedCount: 0 }),
deleteRows: vi.fn().mockResolvedValue({ deletedCount: 0 }),
},
permissions: {},
...overrides,
} as unknown as InstanceAiContext;
}
function suspendCtx(suspendFn: jest.Mock) {
function suspendCtx(suspendFn: Mock) {
return { resumeData: undefined, suspend: suspendFn } as never;
}
@ -75,7 +76,7 @@ describe('data-tables tool', () => {
},
];
const context = createMockContext();
(context.dataTableService.list as jest.Mock).mockResolvedValue(tables);
(context.dataTableService.list as Mock).mockResolvedValue(tables);
const tool = createDataTablesTool(context);
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
@ -86,7 +87,7 @@ describe('data-tables tool', () => {
it('should pass projectId when provided', async () => {
const context = createMockContext();
(context.dataTableService.list as jest.Mock).mockResolvedValue([]);
(context.dataTableService.list as Mock).mockResolvedValue([]);
const tool = createDataTablesTool(context);
await executeTool(tool, { action: 'list' as const, projectId: 'proj-1' }, noSuspendCtx());
@ -104,7 +105,7 @@ describe('data-tables tool', () => {
{ id: 'col-2', name: 'age', type: 'number', index: 1 },
];
const context = createMockContext();
(context.dataTableService.getSchema as jest.Mock).mockResolvedValue(columns);
(context.dataTableService.getSchema as Mock).mockResolvedValue(columns);
const tool = createDataTablesTool(context);
const result = await executeTool(
@ -124,14 +125,14 @@ describe('data-tables tool', () => {
const context = createMockContext({
dataTableService: {
...createMockContext().dataTableService,
resolveTableReference: jest.fn().mockResolvedValue({
resolveTableReference: vi.fn().mockResolvedValue({
id: 'dt-resolved',
name: 'Signups',
projectId: 'proj-1',
}),
},
});
(context.dataTableService.getSchema as jest.Mock).mockResolvedValue(columns);
(context.dataTableService.getSchema as Mock).mockResolvedValue(columns);
const tool = createDataTablesTool(context);
const result = await executeTool(
@ -159,7 +160,7 @@ describe('data-tables tool', () => {
it('should call dataTableService.queryRows with filter, limit, and offset', async () => {
const queryResult = { count: 1, data: [{ email: 'a@b.com' }] };
const context = createMockContext();
(context.dataTableService.queryRows as jest.Mock).mockResolvedValue(queryResult);
(context.dataTableService.queryRows as Mock).mockResolvedValue(queryResult);
const filter = {
type: 'and' as const,
@ -185,7 +186,7 @@ describe('data-tables tool', () => {
it('should include hint when more rows are available', async () => {
const queryResult = { count: 100, data: Array.from({ length: 50 }, (_, i) => ({ id: i })) };
const context = createMockContext();
(context.dataTableService.queryRows as jest.Mock).mockResolvedValue(queryResult);
(context.dataTableService.queryRows as Mock).mockResolvedValue(queryResult);
const tool = createDataTablesTool(context);
const result = await executeTool(
@ -204,7 +205,7 @@ describe('data-tables tool', () => {
it('should include hint with correct remaining count when offset is provided', async () => {
const queryResult = { count: 100, data: Array.from({ length: 10 }, (_, i) => ({ id: i })) };
const context = createMockContext();
(context.dataTableService.queryRows as jest.Mock).mockResolvedValue(queryResult);
(context.dataTableService.queryRows as Mock).mockResolvedValue(queryResult);
const tool = createDataTablesTool(context);
const result = await executeTool(
@ -223,7 +224,7 @@ describe('data-tables tool', () => {
it('should not include hint when all rows are returned', async () => {
const queryResult = { count: 3, data: [{ id: 1 }, { id: 2 }, { id: 3 }] };
const context = createMockContext();
(context.dataTableService.queryRows as jest.Mock).mockResolvedValue(queryResult);
(context.dataTableService.queryRows as Mock).mockResolvedValue(queryResult);
const tool = createDataTablesTool(context);
const result = await executeTool(
@ -241,14 +242,14 @@ describe('data-tables tool', () => {
const context = createMockContext({
dataTableService: {
...createMockContext().dataTableService,
resolveTableReference: jest.fn().mockResolvedValue({
resolveTableReference: vi.fn().mockResolvedValue({
id: 'dt-resolved',
name: 'Signups',
projectId: 'proj-1',
}),
},
});
(context.dataTableService.queryRows as jest.Mock).mockResolvedValue(queryResult);
(context.dataTableService.queryRows as Mock).mockResolvedValue(queryResult);
const tool = createDataTablesTool(context);
const result = await executeTool(
@ -296,13 +297,13 @@ describe('data-tables tool', () => {
it('should suspend for confirmation when permission is not set', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(tool, createInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Create Contacts',
severity: 'info',
@ -315,17 +316,15 @@ describe('data-tables tool', () => {
const context = createMockContext({
permissions: {},
workspaceService: {
getProject: jest
.fn()
.mockResolvedValue({ id: 'proj-1', name: 'My Project', type: 'team' }),
listProjects: jest.fn(),
tagWorkflow: jest.fn(),
listTags: jest.fn(),
createTag: jest.fn(),
cleanupTestExecutions: jest.fn(),
getProject: vi.fn().mockResolvedValue({ id: 'proj-1', name: 'My Project', type: 'team' }),
listProjects: vi.fn(),
tagWorkflow: vi.fn(),
listTags: vi.fn(),
createTag: vi.fn(),
cleanupTestExecutions: vi.fn(),
},
});
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(
@ -335,7 +334,7 @@ describe('data-tables tool', () => {
);
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Create Contacts in project My Project',
}),
@ -345,7 +344,7 @@ describe('data-tables tool', () => {
it('should execute immediately when permission is always_allow', async () => {
const table = { id: 'dt-new', name: 'Contacts' };
const context = createMockContext({ permissions: { createDataTable: 'always_allow' } });
(context.dataTableService.create as jest.Mock).mockResolvedValue(table);
(context.dataTableService.create as Mock).mockResolvedValue(table);
const tool = createDataTablesTool(context);
const result = await executeTool(tool, createInput as never, noSuspendCtx());
@ -361,7 +360,7 @@ describe('data-tables tool', () => {
it('should create after user approves on resume', async () => {
const table = { id: 'dt-new', name: 'Contacts' };
const context = createMockContext({ permissions: {} });
(context.dataTableService.create as jest.Mock).mockResolvedValue(table);
(context.dataTableService.create as Mock).mockResolvedValue(table);
const tool = createDataTablesTool(context);
const result = await executeTool(tool, createInput as never, resumeCtx(true));
@ -391,7 +390,7 @@ describe('data-tables tool', () => {
(wrappedError as Error & { cause: Error }).cause = conflictError;
const context = createMockContext({ permissions: { createDataTable: 'always_allow' } });
(context.dataTableService.create as jest.Mock).mockRejectedValue(wrappedError);
(context.dataTableService.create as Mock).mockRejectedValue(wrappedError);
const tool = createDataTablesTool(context);
const result = await executeTool(tool, createInput as never, noSuspendCtx());
@ -402,7 +401,7 @@ describe('data-tables tool', () => {
it('should throw non-conflict errors normally', async () => {
const context = createMockContext({ permissions: { createDataTable: 'always_allow' } });
(context.dataTableService.create as jest.Mock).mockRejectedValue(
(context.dataTableService.create as Mock).mockRejectedValue(
new Error('Database connection failed'),
);
@ -431,13 +430,13 @@ describe('data-tables tool', () => {
it('should suspend for confirmation when permission needs approval', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(tool, deleteInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Delete dt-1',
severity: 'destructive',
@ -448,7 +447,7 @@ describe('data-tables tool', () => {
it('should include the table name in the suspend message when provided', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(
@ -457,7 +456,7 @@ describe('data-tables tool', () => {
suspendCtx(suspendFn),
);
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Delete Customer data (ID: dt-1)',
}),
@ -521,13 +520,13 @@ describe('data-tables tool', () => {
it('should suspend for confirmation when permission needs approval', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(tool, addColumnInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Add age (number) to dt-1',
severity: 'warning',
@ -539,7 +538,7 @@ describe('data-tables tool', () => {
it('should execute immediately when permission is always_allow', async () => {
const column = { id: 'col-new', name: 'age', type: 'number', index: 2 };
const context = createMockContext({ permissions: { mutateDataTableSchema: 'always_allow' } });
(context.dataTableService.addColumn as jest.Mock).mockResolvedValue(column);
(context.dataTableService.addColumn as Mock).mockResolvedValue(column);
const tool = createDataTablesTool(context);
const result = await executeTool(tool, addColumnInput as never, noSuspendCtx());
@ -555,7 +554,7 @@ describe('data-tables tool', () => {
it('should add column after user approves on resume', async () => {
const column = { id: 'col-new', name: 'age', type: 'number', index: 2 };
const context = createMockContext({ permissions: {} });
(context.dataTableService.addColumn as jest.Mock).mockResolvedValue(column);
(context.dataTableService.addColumn as Mock).mockResolvedValue(column);
const tool = createDataTablesTool(context);
const result = await executeTool(tool, addColumnInput as never, resumeCtx(true));
@ -596,13 +595,13 @@ describe('data-tables tool', () => {
it('should suspend for confirmation when permission needs approval', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(tool, deleteColumnInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Delete col-1 from dt-1',
severity: 'destructive',
@ -668,13 +667,13 @@ describe('data-tables tool', () => {
it('should suspend for confirmation when permission needs approval', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(tool, renameColumnInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Rename col-1 to full_name in dt-1',
severity: 'warning',
@ -745,13 +744,13 @@ describe('data-tables tool', () => {
it('should suspend for confirmation when permission needs approval', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(tool, insertRowsInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Insert 2 row(s) into dt-1',
severity: 'warning',
@ -762,7 +761,7 @@ describe('data-tables tool', () => {
it('should execute immediately when permission is always_allow', async () => {
const context = createMockContext({ permissions: { mutateDataTableRows: 'always_allow' } });
(context.dataTableService.insertRows as jest.Mock).mockResolvedValue({ insertedCount: 2 });
(context.dataTableService.insertRows as Mock).mockResolvedValue({ insertedCount: 2 });
const tool = createDataTablesTool(context);
const result = await executeTool(tool, insertRowsInput as never, noSuspendCtx());
@ -777,7 +776,7 @@ describe('data-tables tool', () => {
it('should insert rows after user approves on resume', async () => {
const context = createMockContext({ permissions: {} });
(context.dataTableService.insertRows as jest.Mock).mockResolvedValue({ insertedCount: 2 });
(context.dataTableService.insertRows as Mock).mockResolvedValue({ insertedCount: 2 });
const tool = createDataTablesTool(context);
const result = await executeTool(tool, insertRowsInput as never, resumeCtx(true));
@ -802,7 +801,7 @@ describe('data-tables tool', () => {
it('should return artifact metadata (dataTableId, tableName, projectId) in result', async () => {
const context = createMockContext({ permissions: { mutateDataTableRows: 'always_allow' } });
(context.dataTableService.insertRows as jest.Mock).mockResolvedValue({
(context.dataTableService.insertRows as Mock).mockResolvedValue({
insertedCount: 3,
dataTableId: 'dt-1',
tableName: 'Orders',
@ -846,13 +845,13 @@ describe('data-tables tool', () => {
it('should suspend for confirmation when permission needs approval', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(tool, updateRowsInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Update rows in dt-1',
severity: 'warning',
@ -863,7 +862,7 @@ describe('data-tables tool', () => {
it('should execute immediately when permission is always_allow', async () => {
const context = createMockContext({ permissions: { mutateDataTableRows: 'always_allow' } });
(context.dataTableService.updateRows as jest.Mock).mockResolvedValue({ updatedCount: 5 });
(context.dataTableService.updateRows as Mock).mockResolvedValue({ updatedCount: 5 });
const tool = createDataTablesTool(context);
const result = await executeTool(tool, updateRowsInput as never, noSuspendCtx());
@ -879,7 +878,7 @@ describe('data-tables tool', () => {
it('should update rows after user approves on resume', async () => {
const context = createMockContext({ permissions: {} });
(context.dataTableService.updateRows as jest.Mock).mockResolvedValue({ updatedCount: 3 });
(context.dataTableService.updateRows as Mock).mockResolvedValue({ updatedCount: 3 });
const tool = createDataTablesTool(context);
const result = await executeTool(tool, updateRowsInput as never, resumeCtx(true));
@ -928,13 +927,13 @@ describe('data-tables tool', () => {
it('should suspend with destructive confirmation including filter description', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
await executeTool(tool, deleteRowsInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Delete rows from dt-1 where status eq inactive',
severity: 'destructive',
@ -945,7 +944,7 @@ describe('data-tables tool', () => {
it('should format filter description with multiple conditions joined by filter type', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const multiFilterInput = {
action: 'delete-rows' as const,
@ -963,7 +962,7 @@ describe('data-tables tool', () => {
await executeTool(tool, multiFilterInput as never, suspendCtx(suspendFn));
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Delete rows from dt-1 where status eq inactive or age lt 18',
}),
@ -972,7 +971,7 @@ describe('data-tables tool', () => {
it('should execute immediately when permission is always_allow', async () => {
const context = createMockContext({ permissions: { mutateDataTableRows: 'always_allow' } });
(context.dataTableService.deleteRows as jest.Mock).mockResolvedValue({
(context.dataTableService.deleteRows as Mock).mockResolvedValue({
deletedCount: 10,
dataTableId: 'dt-1',
tableName: 'Users',
@ -998,7 +997,7 @@ describe('data-tables tool', () => {
it('should delete rows after user approves on resume', async () => {
const context = createMockContext({ permissions: {} });
(context.dataTableService.deleteRows as jest.Mock).mockResolvedValue({
(context.dataTableService.deleteRows as Mock).mockResolvedValue({
deletedCount: 7,
dataTableId: 'dt-1',
tableName: 'Users',

View File

@ -1,4 +1,5 @@
import type { InstanceAiPermissions } from '@n8n/api-types';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext, ExecutionResult } from '../../types';
@ -14,17 +15,17 @@ function createMockContext(
return {
userId: 'user-1',
workflowService: {
get: jest.fn().mockResolvedValue({ id: 'wf-1', name: 'Fetched Name' }),
get: vi.fn().mockResolvedValue({ id: 'wf-1', name: 'Fetched Name' }),
} as unknown as InstanceAiContext['workflowService'],
executionService: {
list: jest.fn(),
getStatus: jest.fn(),
run: jest.fn(),
getResult: jest.fn(),
stop: jest.fn(),
getDebugInfo: jest.fn(),
getNodeOutput: jest.fn(),
getResolvedNodeParameters: jest.fn(),
list: vi.fn(),
getStatus: vi.fn(),
run: vi.fn(),
getResult: vi.fn(),
stop: vi.fn(),
getDebugInfo: vi.fn(),
getNodeOutput: vi.fn(),
getResolvedNodeParameters: vi.fn(),
},
credentialService: {} as never,
nodeService: {} as never,
@ -34,10 +35,10 @@ function createMockContext(
} as unknown as InstanceAiContext;
}
function createAgentCtx(opts: { resumeData?: unknown; suspend?: jest.Mock } = {}) {
function createAgentCtx(opts: { resumeData?: unknown; suspend?: Mock } = {}) {
return {
resumeData: opts.resumeData,
suspend: opts.suspend ?? jest.fn(),
suspend: opts.suspend ?? vi.fn(),
};
}
@ -59,7 +60,7 @@ describe('executions tool', () => {
},
];
const context = createMockContext();
(context.executionService.list as jest.Mock).mockResolvedValue(executions);
(context.executionService.list as Mock).mockResolvedValue(executions);
const tool = createExecutionsTool(context);
const result = await executeTool(tool, { action: 'list' as const }, {} as never);
@ -74,7 +75,7 @@ describe('executions tool', () => {
it('should pass filters to executionService.list', async () => {
const context = createMockContext();
(context.executionService.list as jest.Mock).mockResolvedValue([]);
(context.executionService.list as Mock).mockResolvedValue([]);
const tool = createExecutionsTool(context);
await executeTool(
@ -105,7 +106,7 @@ describe('executions tool', () => {
status: 'running',
};
const context = createMockContext();
(context.executionService.getStatus as jest.Mock).mockResolvedValue(executionStatus);
(context.executionService.getStatus as Mock).mockResolvedValue(executionStatus);
const tool = createExecutionsTool(context);
const result = await executeTool(
@ -144,11 +145,11 @@ describe('executions tool', () => {
});
it('should suspend for confirmation using the looked-up workflow name', async () => {
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const context = createMockContext({
permissions: {},
});
(context.workflowService.get as jest.Mock).mockResolvedValue({
(context.workflowService.get as Mock).mockResolvedValue({
id: 'wf-1',
name: 'My Workflow',
});
@ -176,9 +177,9 @@ describe('executions tool', () => {
});
it('should fall back to workflowId in message when lookup fails', async () => {
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const context = createMockContext({ permissions: {} });
(context.workflowService.get as jest.Mock).mockRejectedValue(new Error('not found'));
(context.workflowService.get as Mock).mockRejectedValue(new Error('not found'));
const tool = createExecutionsTool(context);
await executeTool(
@ -221,7 +222,7 @@ describe('executions tool', () => {
status: 'success',
};
const context = createMockContext({ permissions: {} });
(context.executionService.run as jest.Mock).mockResolvedValue(executionResult);
(context.executionService.run as Mock).mockResolvedValue(executionResult);
const tool = createExecutionsTool(context);
const result = await executeTool(
@ -251,9 +252,9 @@ describe('executions tool', () => {
const context = createMockContext({
permissions: { runWorkflow: 'always_allow' },
});
(context.executionService.run as jest.Mock).mockResolvedValue(executionResult);
(context.executionService.run as Mock).mockResolvedValue(executionResult);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createExecutionsTool(context);
const result = await executeTool(
tool,
@ -272,7 +273,7 @@ describe('executions tool', () => {
const context = createMockContext({
permissions: { runWorkflow: 'always_allow' },
});
(context.executionService.run as jest.Mock).mockResolvedValue({
(context.executionService.run as Mock).mockResolvedValue({
executionId: 'exec-1',
status: 'success',
});
@ -295,11 +296,11 @@ describe('executions tool', () => {
permissions: { runWorkflow: 'always_allow' },
allowedRunWorkflowIds: new Set(['wf-1']),
});
(context.executionService.run as jest.Mock).mockResolvedValue({
(context.executionService.run as Mock).mockResolvedValue({
executionId: 'exec-1',
status: 'success',
});
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createExecutionsTool(context);
await executeTool(
@ -319,8 +320,8 @@ describe('executions tool', () => {
permissions: { runWorkflow: 'always_allow' },
allowedRunWorkflowIds: new Set(['wf-other']),
});
(context.workflowService.get as jest.Mock).mockResolvedValue({ name: 'Off-scope WF' });
const suspendFn = jest.fn();
(context.workflowService.get as Mock).mockResolvedValue({ name: 'Off-scope WF' });
const suspendFn = vi.fn();
const tool = createExecutionsTool(context);
const result = await executeTool(
@ -357,7 +358,7 @@ describe('executions tool', () => {
],
};
const context = createMockContext();
(context.executionService.getDebugInfo as jest.Mock).mockResolvedValue(debugInfo);
(context.executionService.getDebugInfo as Mock).mockResolvedValue(debugInfo);
const tool = createExecutionsTool(context);
const result = await executeTool(
@ -382,7 +383,7 @@ describe('executions tool', () => {
returned: { from: 0, to: 0 },
};
const context = createMockContext();
(context.executionService.getNodeOutput as jest.Mock).mockResolvedValue(nodeOutput);
(context.executionService.getNodeOutput as Mock).mockResolvedValue(nodeOutput);
const tool = createExecutionsTool(context);
const result = await executeTool(
@ -406,7 +407,7 @@ describe('executions tool', () => {
it('should pass undefined options when not provided', async () => {
const context = createMockContext();
(context.executionService.getNodeOutput as jest.Mock).mockResolvedValue({
(context.executionService.getNodeOutput as Mock).mockResolvedValue({
nodeName: 'Set',
items: [],
totalItems: 0,
@ -445,9 +446,7 @@ describe('executions tool', () => {
emptyResolutions: [],
};
const context = createMockContext();
(context.executionService.getResolvedNodeParameters as jest.Mock).mockResolvedValue(
resolution,
);
(context.executionService.getResolvedNodeParameters as Mock).mockResolvedValue(resolution);
const tool = createExecutionsTool(context);
const result = await executeTool(
@ -472,7 +471,7 @@ describe('executions tool', () => {
it('should pass undefined options when itemIndex/runIndex are omitted', async () => {
const context = createMockContext();
(context.executionService.getResolvedNodeParameters as jest.Mock).mockResolvedValue({
(context.executionService.getResolvedNodeParameters as Mock).mockResolvedValue({
nodeName: 'Set',
runIndex: 0,
itemIndex: 0,
@ -512,9 +511,7 @@ describe('executions tool', () => {
suppressed: 'parameter-values-disabled',
};
const context = createMockContext();
(context.executionService.getResolvedNodeParameters as jest.Mock).mockResolvedValue(
suppressed,
);
(context.executionService.getResolvedNodeParameters as Mock).mockResolvedValue(suppressed);
const tool = createExecutionsTool(context);
const result = await executeTool(
@ -537,7 +534,7 @@ describe('executions tool', () => {
it('should call executionService.stop with execution ID', async () => {
const stopResult = { success: true, message: 'Execution cancelled' };
const context = createMockContext();
(context.executionService.stop as jest.Mock).mockResolvedValue(stopResult);
(context.executionService.stop as Mock).mockResolvedValue(stopResult);
const tool = createExecutionsTool(context);
const result = await executeTool(

View File

@ -2,105 +2,109 @@ import { createAllTools, createOrchestratorDomainTools } from '..';
import { isParseableAttachment } from '../../parsers/structured-file-parser';
import type { InstanceAiContext } from '../../types';
jest.mock('../../parsers/structured-file-parser', () => ({
isParseableAttachment: jest.fn(() => false),
vi.mock('../../parsers/structured-file-parser', () => ({
isParseableAttachment: vi.fn(() => false),
}));
jest.mock('../attachments/parse-file.tool', () => ({
createParseFileTool: jest.fn(() => ({ id: 'parse-file' })),
vi.mock('../attachments/parse-file.tool', () => ({
createParseFileTool: vi.fn(() => ({ id: 'parse-file' })),
}));
jest.mock('../credentials.tool', () => ({
vi.mock('../credentials.tool', () => ({
CREDENTIALS_TOOL_ID: 'credentials',
createCredentialsTool: jest.fn(() => ({ id: 'credentials' })),
createCredentialsTool: vi.fn(() => ({ id: 'credentials' })),
}));
jest.mock('../data-tables.tool', () => ({
vi.mock('../data-tables.tool', () => ({
DATA_TABLES_TOOL_ID: 'data-tables',
createDataTablesTool: jest.fn((_context: unknown, scope?: string) => ({
createDataTablesTool: vi.fn((_context: unknown, scope?: string) => ({
id: scope ? `data-tables-${scope}` : 'data-tables',
})),
}));
jest.mock('../executions.tool', () => ({
createExecutionsTool: jest.fn(() => ({ id: 'executions' })),
vi.mock('../executions.tool', () => ({
createExecutionsTool: vi.fn(() => ({ id: 'executions' })),
}));
jest.mock('../nodes.tool', () => ({
createNodesTool: jest.fn((_context: unknown, scope?: string) => ({
vi.mock('../nodes.tool', () => ({
createNodesTool: vi.fn((_context: unknown, scope?: string) => ({
id: scope ? `nodes-${scope}` : 'nodes',
})),
}));
jest.mock('../orchestration/complete-checkpoint.tool', () => ({
createCompleteCheckpointTool: jest.fn(() => ({ id: 'complete-checkpoint' })),
vi.mock('../orchestration/build-workflow-agent.tool', () => ({
createBuildWorkflowAgentTool: vi.fn(() => ({ id: 'build-workflow-with-agent' })),
}));
jest.mock('../orchestration/delegate.tool', () => ({
createDelegateTool: jest.fn(() => ({ id: 'delegate' })),
vi.mock('../orchestration/complete-checkpoint.tool', () => ({
createCompleteCheckpointTool: vi.fn(() => ({ id: 'complete-checkpoint' })),
}));
jest.mock('../evals/evals.tool', () => ({
createEvalsTool: jest.fn(() => ({ id: 'evals' })),
vi.mock('../orchestration/delegate.tool', () => ({
createDelegateTool: vi.fn(() => ({ id: 'delegate' })),
}));
jest.mock('../orchestration/eval-setup-agent.tool', () => ({
createEvalSetupAgentTool: jest.fn(() => ({ id: 'eval-setup-with-agent' })),
vi.mock('../evals/evals.tool', () => ({
createEvalsTool: vi.fn(() => ({ id: 'evals' })),
}));
jest.mock('../orchestration/eval-data-agent.tool', () => ({
createEvalDataAgentTool: jest.fn(() => ({ id: 'eval-data' })),
vi.mock('../orchestration/eval-setup-agent.tool', () => ({
createEvalSetupAgentTool: vi.fn(() => ({ id: 'eval-setup-with-agent' })),
}));
jest.mock('../orchestration/plan-with-agent.tool', () => ({
createPlanWithAgentTool: jest.fn(() => ({ id: 'plan' })),
vi.mock('../orchestration/eval-data-agent.tool', () => ({
createEvalDataAgentTool: vi.fn(() => ({ id: 'eval-data' })),
}));
jest.mock('../orchestration/plan.tool', () => ({
createPlanTool: jest.fn(() => ({ id: 'create-tasks' })),
vi.mock('../orchestration/plan-with-agent.tool', () => ({
createPlanWithAgentTool: vi.fn(() => ({ id: 'plan' })),
}));
jest.mock('../orchestration/report-verification-verdict.tool', () => ({
createReportVerificationVerdictTool: jest.fn(() => ({ id: 'report-verification-verdict' })),
vi.mock('../orchestration/plan.tool', () => ({
createPlanTool: vi.fn(() => ({ id: 'create-tasks' })),
}));
jest.mock('../orchestration/verify-built-workflow.tool', () => ({
createVerifyBuiltWorkflowTool: jest.fn(() => ({ id: 'verify-built-workflow' })),
vi.mock('../orchestration/report-verification-verdict.tool', () => ({
createReportVerificationVerdictTool: vi.fn(() => ({ id: 'report-verification-verdict' })),
}));
jest.mock('../research.tool', () => ({
createResearchTool: jest.fn(() => ({ id: 'research' })),
vi.mock('../orchestration/verify-built-workflow.tool', () => ({
createVerifyBuiltWorkflowTool: vi.fn(() => ({ id: 'verify-built-workflow' })),
}));
jest.mock('../shared/ask-user.tool', () => ({
vi.mock('../research.tool', () => ({
createResearchTool: vi.fn(() => ({ id: 'research' })),
}));
vi.mock('../shared/ask-user.tool', () => ({
ASK_USER_TOOL_ID: 'ask-user',
createAskUserTool: jest.fn(() => ({ id: 'ask-user' })),
createAskUserTool: vi.fn(() => ({ id: 'ask-user' })),
}));
jest.mock('../task-control.tool', () => ({
createTaskControlTool: jest.fn(() => ({ id: 'task-control' })),
vi.mock('../task-control.tool', () => ({
createTaskControlTool: vi.fn(() => ({ id: 'task-control' })),
}));
jest.mock('../workflows/apply-workflow-credentials.tool', () => ({
createApplyWorkflowCredentialsTool: jest.fn(() => ({ id: 'apply-workflow-credentials' })),
vi.mock('../workflows/apply-workflow-credentials.tool', () => ({
createApplyWorkflowCredentialsTool: vi.fn(() => ({ id: 'apply-workflow-credentials' })),
}));
jest.mock('../workflows/build-workflow.tool', () => ({
createBuildWorkflowTool: jest.fn(() => ({ id: 'build-workflow' })),
vi.mock('../workflows/build-workflow.tool', () => ({
createBuildWorkflowTool: vi.fn(() => ({ id: 'build-workflow' })),
}));
jest.mock('../workflows.tool', () => ({
createWorkflowsTool: jest.fn((_context: unknown, options?: unknown) => ({
vi.mock('../workflows.tool', () => ({
createWorkflowsTool: vi.fn((_context: unknown, options?: unknown) => ({
id: options ? 'workflows-filtered' : 'workflows',
})),
}));
jest.mock('../workspace.tool', () => ({
createWorkspaceTool: jest.fn(() => ({ id: 'workspace' })),
vi.mock('../workspace.tool', () => ({
createWorkspaceTool: vi.fn(() => ({ id: 'workspace' })),
}));
jest.mock('../filesystem/create-tools-from-mcp-server', () => ({
createToolsFromLocalMcpServer: jest.fn(() => ({
vi.mock('../filesystem/create-tools-from-mcp-server', () => ({
createToolsFromLocalMcpServer: vi.fn(() => ({
browser_connect: { id: 'browser_connect' },
browser_navigate: { id: 'browser_navigate' },
})),
@ -109,15 +113,15 @@ jest.mock('../filesystem/create-tools-from-mcp-server', () => ({
function makeContext(overrides: Partial<InstanceAiContext> = {}): InstanceAiContext {
return {
userId: 'user-a',
logger: { warn: jest.fn() },
logger: { warn: vi.fn() },
...overrides,
} as unknown as InstanceAiContext;
}
describe('domain tool construction', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(isParseableAttachment).mockReturnValue(false);
vi.clearAllMocks();
vi.mocked(isParseableAttachment).mockReturnValue(false);
});
it('creates the native full domain tool map', () => {
@ -139,7 +143,7 @@ describe('domain tool construction', () => {
});
});
it('creates the native orchestrator domain tool map', () => {
it('creates the native orchestrator domain tool map', async () => {
const context = makeContext();
const orchestratorTools = createOrchestratorDomainTools(context);
@ -157,9 +161,9 @@ describe('domain tool construction', () => {
'build-workflow': { id: 'build-workflow' },
});
const { createWorkflowsTool } = jest.requireMock('../workflows.tool');
const { createNodesTool } = jest.requireMock('../nodes.tool');
const { createDataTablesTool } = jest.requireMock('../data-tables.tool');
const { createWorkflowsTool } = await import('../workflows.tool');
const { createNodesTool } = await import('../nodes.tool');
const { createDataTablesTool } = await import('../data-tables.tool');
expect(createWorkflowsTool).toHaveBeenCalledWith(context);
expect(createNodesTool).toHaveBeenCalledWith(context);
expect(createDataTablesTool).toHaveBeenCalledWith(context);
@ -177,7 +181,7 @@ describe('domain tool construction', () => {
});
it('includes parse-file tools when attachments are parseable', () => {
jest.mocked(isParseableAttachment).mockReturnValue(true);
vi.mocked(isParseableAttachment).mockReturnValue(true);
const context = makeContext({
currentUserAttachments: [{ data: '', mimeType: 'text/html', fileName: 'page.html' }],
});

View File

@ -1,3 +1,5 @@
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../types';
import { createNodesTool } from '../nodes.tool';
@ -6,49 +8,49 @@ function createMockContext(overrides: Partial<InstanceAiContext> = {}): Instance
return {
userId: 'user-1',
workflowService: {
list: jest.fn(),
get: jest.fn(),
getAsWorkflowJSON: jest.fn(),
createFromWorkflowJSON: jest.fn(),
updateFromWorkflowJSON: jest.fn(),
archive: jest.fn(),
delete: jest.fn(),
publish: jest.fn(),
unpublish: jest.fn(),
list: vi.fn(),
get: vi.fn(),
getAsWorkflowJSON: vi.fn(),
createFromWorkflowJSON: vi.fn(),
updateFromWorkflowJSON: vi.fn(),
archive: vi.fn(),
delete: vi.fn(),
publish: vi.fn(),
unpublish: vi.fn(),
},
executionService: {
list: jest.fn(),
run: jest.fn(),
getStatus: jest.fn(),
getResult: jest.fn(),
stop: jest.fn(),
getDebugInfo: jest.fn(),
getNodeOutput: jest.fn(),
list: vi.fn(),
run: vi.fn(),
getStatus: vi.fn(),
getResult: vi.fn(),
stop: vi.fn(),
getDebugInfo: vi.fn(),
getNodeOutput: vi.fn(),
},
credentialService: {
list: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
test: jest.fn(),
list: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
test: vi.fn(),
},
nodeService: {
listAvailable: jest.fn(),
getDescription: jest.fn(),
listSearchable: jest.fn(),
exploreResources: jest.fn(),
listAvailable: vi.fn(),
getDescription: vi.fn(),
listSearchable: vi.fn(),
exploreResources: vi.fn(),
},
dataTableService: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
getSchema: jest.fn(),
addColumn: jest.fn(),
deleteColumn: jest.fn(),
renameColumn: jest.fn(),
queryRows: jest.fn(),
insertRows: jest.fn(),
updateRows: jest.fn(),
deleteRows: jest.fn(),
list: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
getSchema: vi.fn(),
addColumn: vi.fn(),
deleteColumn: vi.fn(),
renameColumn: vi.fn(),
queryRows: vi.fn(),
insertRows: vi.fn(),
updateRows: vi.fn(),
deleteRows: vi.fn(),
},
permissions: {},
...overrides,
@ -72,7 +74,7 @@ describe('nodes tool', () => {
results: [{ name: 'Sheet1', value: 'sheet-1' }],
paginationToken: undefined,
};
(context.nodeService.exploreResources as jest.Mock).mockResolvedValue(mockResult);
(context.nodeService.exploreResources as Mock).mockResolvedValue(mockResult);
const tool = createNodesTool(context, 'orchestrator');
const result = await executeTool(
@ -119,7 +121,7 @@ describe('nodes tool', () => {
},
];
const context = createMockContext();
(context.nodeService.listAvailable as jest.Mock).mockResolvedValue(nodes);
(context.nodeService.listAvailable as Mock).mockResolvedValue(nodes);
const tool = createNodesTool(context, 'full');
const result = await executeTool(
@ -161,9 +163,7 @@ describe('nodes tool', () => {
it('should handle errors from exploreResources gracefully', async () => {
const context = createMockContext();
(context.nodeService.exploreResources as jest.Mock).mockRejectedValue(
new Error('Auth failed'),
);
(context.nodeService.exploreResources as Mock).mockRejectedValue(new Error('Auth failed'));
const tool = createNodesTool(context, 'full');
const result = await executeTool(
@ -223,11 +223,11 @@ describe('nodes tool', () => {
it('should surface node-level builder hints from type definitions', async () => {
const context = createMockContext({
nodeService: {
listAvailable: jest.fn(),
getDescription: jest.fn(),
listSearchable: jest.fn(),
exploreResources: jest.fn(),
getNodeTypeDefinition: jest.fn().mockResolvedValue({
listAvailable: vi.fn(),
getDescription: vi.fn(),
listSearchable: vi.fn(),
exploreResources: vi.fn(),
getNodeTypeDefinition: vi.fn().mockResolvedValue({
content: 'export type IfNode = unknown;',
version: 'v23',
builderHint: 'Always include options, conditions, and combinator.',
@ -258,7 +258,7 @@ describe('nodes tool', () => {
describe('describe action', () => {
it('should return found: false when node type is not found', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockRejectedValue(new Error('not found'));
(context.nodeService.getDescription as Mock).mockRejectedValue(new Error('not found'));
const tool = createNodesTool(context, 'full');
const result = await executeTool(

View File

@ -1,4 +1,5 @@
import type { InstanceAiPermissions } from '@n8n/api-types';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../types';
@ -19,8 +20,8 @@ function createMockContext(
nodeService: {} as never,
dataTableService: {} as never,
webResearchService: {
search: jest.fn(),
fetchUrl: jest.fn(),
search: vi.fn(),
fetchUrl: vi.fn(),
},
domainAccessTracker: undefined,
runId: 'test-run',
@ -29,8 +30,8 @@ function createMockContext(
} as unknown as InstanceAiContext;
}
function createAgentCtx(opts: { resumeData?: unknown; suspend?: jest.Mock } = {}) {
const suspend = opts.suspend ?? jest.fn();
function createAgentCtx(opts: { resumeData?: unknown; suspend?: Mock } = {}) {
const suspend = opts.suspend ?? vi.fn();
return {
resumeData: opts.resumeData,
suspend,
@ -51,7 +52,7 @@ describe('research tool', () => {
],
};
const context = createMockContext({ permissions: { webSearch: 'always_allow' } });
context.webResearchService!.search = jest.fn().mockResolvedValue(searchResponse);
context.webResearchService!.search = vi.fn().mockResolvedValue(searchResponse);
const tool = createResearchTool(context);
const result = await executeTool(
@ -70,7 +71,7 @@ describe('research tool', () => {
it('should pass maxResults and includeDomains to search', async () => {
const searchResponse = { query: 'stripe api', results: [] };
const context = createMockContext({ permissions: { webSearch: 'always_allow' } });
context.webResearchService!.search = jest.fn().mockResolvedValue(searchResponse);
context.webResearchService!.search = vi.fn().mockResolvedValue(searchResponse);
const tool = createResearchTool(context);
await executeTool(
@ -102,7 +103,7 @@ describe('research tool', () => {
],
};
const context = createMockContext({ permissions: { webSearch: 'always_allow' } });
context.webResearchService!.search = jest.fn().mockResolvedValue(searchResponse);
context.webResearchService!.search = vi.fn().mockResolvedValue(searchResponse);
const tool = createResearchTool(context);
const result = await executeTool(
@ -134,7 +135,7 @@ describe('research tool', () => {
],
};
const context = createMockContext({ permissions: { webSearch: 'always_allow' } });
context.webResearchService!.search = jest.fn().mockResolvedValue(searchResponse);
context.webResearchService!.search = vi.fn().mockResolvedValue(searchResponse);
const tool = createResearchTool(context);
const result = await executeTool(
@ -166,7 +167,7 @@ describe('research tool', () => {
],
};
const context = createMockContext({ permissions: { webSearch: 'always_allow' } });
context.webResearchService!.search = jest.fn().mockResolvedValue(searchResponse);
context.webResearchService!.search = vi.fn().mockResolvedValue(searchResponse);
const tool = createResearchTool(context);
const result = await executeTool(
@ -198,7 +199,7 @@ describe('research tool', () => {
it('should return empty results when webResearchService.search is undefined', async () => {
const context = createMockContext({
webResearchService: { fetchUrl: jest.fn() } as never,
webResearchService: { fetchUrl: vi.fn() } as never,
});
const tool = createResearchTool(context);
@ -215,7 +216,7 @@ describe('research tool', () => {
it('should return empty results when permission is blocked', async () => {
const context = createMockContext({ permissions: { webSearch: 'blocked' } });
context.webResearchService!.search = jest.fn();
context.webResearchService!.search = vi.fn();
const tool = createResearchTool(context);
const result = await tool.handler!(
@ -228,21 +229,21 @@ describe('research tool', () => {
});
it('should suspend when web-search is not yet approved', async () => {
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tracker = {
isHostAllowed: jest.fn(),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isWebSearchAllowed: jest.fn().mockReturnValue(false),
approveWebSearch: jest.fn(),
approveWebSearchOnce: jest.fn(),
isHostAllowed: vi.fn(),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
isWebSearchAllowed: vi.fn().mockReturnValue(false),
approveWebSearch: vi.fn(),
approveWebSearchOnce: vi.fn(),
};
const context = createMockContext({
domainAccessTracker: tracker as never,
permissions: {},
});
context.webResearchService!.search = jest.fn();
context.webResearchService!.search = vi.fn();
const tool = createResearchTool(context);
await tool.handler!(
@ -263,19 +264,19 @@ describe('research tool', () => {
it('should skip suspension when web-search is already approved in tracker', async () => {
const tracker = {
isHostAllowed: jest.fn(),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isWebSearchAllowed: jest.fn().mockReturnValue(true),
approveWebSearch: jest.fn(),
approveWebSearchOnce: jest.fn(),
isHostAllowed: vi.fn(),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
isWebSearchAllowed: vi.fn().mockReturnValue(true),
approveWebSearch: vi.fn(),
approveWebSearchOnce: vi.fn(),
};
const context = createMockContext({ domainAccessTracker: tracker as never });
const searchResponse = { query: 'q', results: [] };
context.webResearchService!.search = jest.fn().mockResolvedValue(searchResponse);
context.webResearchService!.search = vi.fn().mockResolvedValue(searchResponse);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createResearchTool(context);
await tool.handler!(
{ action: 'web-search' as const, query: 'q' },
@ -288,17 +289,17 @@ describe('research tool', () => {
it('should grant transient approval and run search on resume with allow_once', async () => {
const tracker = {
isHostAllowed: jest.fn(),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isWebSearchAllowed: jest.fn().mockReturnValue(false),
approveWebSearch: jest.fn(),
approveWebSearchOnce: jest.fn(),
isHostAllowed: vi.fn(),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
isWebSearchAllowed: vi.fn().mockReturnValue(false),
approveWebSearch: vi.fn(),
approveWebSearchOnce: vi.fn(),
};
const context = createMockContext({ domainAccessTracker: tracker as never });
const searchResponse = { query: 'q', results: [] };
context.webResearchService!.search = jest.fn().mockResolvedValue(searchResponse);
context.webResearchService!.search = vi.fn().mockResolvedValue(searchResponse);
const tool = createResearchTool(context);
await tool.handler!(
@ -315,16 +316,16 @@ describe('research tool', () => {
it('should grant persistent approval on resume with allow_domain', async () => {
const tracker = {
isHostAllowed: jest.fn(),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isWebSearchAllowed: jest.fn().mockReturnValue(false),
approveWebSearch: jest.fn(),
approveWebSearchOnce: jest.fn(),
isHostAllowed: vi.fn(),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
isWebSearchAllowed: vi.fn().mockReturnValue(false),
approveWebSearch: vi.fn(),
approveWebSearchOnce: vi.fn(),
};
const context = createMockContext({ domainAccessTracker: tracker as never });
context.webResearchService!.search = jest.fn().mockResolvedValue({ query: 'q', results: [] });
context.webResearchService!.search = vi.fn().mockResolvedValue({ query: 'q', results: [] });
const tool = createResearchTool(context);
await tool.handler!(
@ -340,16 +341,16 @@ describe('research tool', () => {
it('should return empty results when resumed with denial', async () => {
const tracker = {
isHostAllowed: jest.fn(),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isWebSearchAllowed: jest.fn().mockReturnValue(false),
approveWebSearch: jest.fn(),
approveWebSearchOnce: jest.fn(),
isHostAllowed: vi.fn(),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
isWebSearchAllowed: vi.fn().mockReturnValue(false),
approveWebSearch: vi.fn(),
approveWebSearchOnce: vi.fn(),
};
const context = createMockContext({ domainAccessTracker: tracker as never });
context.webResearchService!.search = jest.fn();
context.webResearchService!.search = vi.fn();
const tool = createResearchTool(context);
const result = await tool.handler!(
@ -377,7 +378,7 @@ describe('research tool', () => {
const context = createMockContext({
permissions: { fetchUrl: 'always_allow' },
});
context.webResearchService!.fetchUrl = jest.fn().mockResolvedValue(fetchedPage);
context.webResearchService!.fetchUrl = vi.fn().mockResolvedValue(fetchedPage);
const tool = createResearchTool(context);
const result = await executeTool(
@ -410,7 +411,7 @@ describe('research tool', () => {
contentLength: 70,
};
const context = createMockContext({ permissions: { fetchUrl: 'always_allow' } });
context.webResearchService!.fetchUrl = jest.fn().mockResolvedValue(fetchedPage);
context.webResearchService!.fetchUrl = vi.fn().mockResolvedValue(fetchedPage);
const tool = createResearchTool(context);
const result = await executeTool(
@ -449,12 +450,12 @@ describe('research tool', () => {
});
it('should suspend when domain is not allowed and needs approval', async () => {
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tracker = {
isHostAllowed: jest.fn().mockReturnValue(false),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isHostAllowed: vi.fn().mockReturnValue(false),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
};
const context = createMockContext({
domainAccessTracker: tracker as never,
@ -513,7 +514,7 @@ describe('research tool', () => {
const context = createMockContext({
permissions: { fetchUrl: 'always_allow' },
});
context.webResearchService!.fetchUrl = jest.fn().mockResolvedValue(fetchedPage);
context.webResearchService!.fetchUrl = vi.fn().mockResolvedValue(fetchedPage);
const tool = createResearchTool(context);
const result = await executeTool(
@ -536,15 +537,15 @@ describe('research tool', () => {
contentLength: 15,
};
const tracker = {
isHostAllowed: jest.fn().mockReturnValue(false),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isHostAllowed: vi.fn().mockReturnValue(false),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
};
const context = createMockContext({
domainAccessTracker: tracker as never,
});
context.webResearchService!.fetchUrl = jest.fn().mockResolvedValue(fetchedPage);
context.webResearchService!.fetchUrl = vi.fn().mockResolvedValue(fetchedPage);
const tool = createResearchTool(context);
const result = await executeTool(
@ -562,10 +563,10 @@ describe('research tool', () => {
it('should deny access when resumed with denial', async () => {
const tracker = {
isHostAllowed: jest.fn().mockReturnValue(false),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isHostAllowed: vi.fn().mockReturnValue(false),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
};
const context = createMockContext({
domainAccessTracker: tracker as never,
@ -597,15 +598,15 @@ describe('research tool', () => {
contentLength: 7,
};
const tracker = {
isHostAllowed: jest.fn().mockReturnValue(false),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isHostAllowed: vi.fn().mockReturnValue(false),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
};
const context = createMockContext({
domainAccessTracker: tracker as never,
});
context.webResearchService!.fetchUrl = jest.fn().mockResolvedValue(fetchedPage);
context.webResearchService!.fetchUrl = vi.fn().mockResolvedValue(fetchedPage);
const tool = createResearchTool(context);
await executeTool(
@ -629,15 +630,15 @@ describe('research tool', () => {
contentLength: 7,
};
const tracker = {
isHostAllowed: jest.fn().mockReturnValue(false),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isHostAllowed: vi.fn().mockReturnValue(false),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
};
const context = createMockContext({
domainAccessTracker: tracker as never,
});
context.webResearchService!.fetchUrl = jest.fn().mockResolvedValue(fetchedPage);
context.webResearchService!.fetchUrl = vi.fn().mockResolvedValue(fetchedPage);
const tool = createResearchTool(context);
await executeTool(
@ -661,17 +662,17 @@ describe('research tool', () => {
contentLength: 15,
};
const tracker = {
isHostAllowed: jest.fn().mockReturnValue(true),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
isHostAllowed: vi.fn().mockReturnValue(true),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
};
const context = createMockContext({
domainAccessTracker: tracker as never,
});
context.webResearchService!.fetchUrl = jest.fn().mockResolvedValue(fetchedPage);
context.webResearchService!.fetchUrl = vi.fn().mockResolvedValue(fetchedPage);
const suspendFn = jest.fn();
const suspendFn = vi.fn();
const tool = createResearchTool(context);
await executeTool(
tool,
@ -695,7 +696,7 @@ describe('research tool', () => {
const context = createMockContext({
permissions: { fetchUrl: 'always_allow' },
});
context.webResearchService!.fetchUrl = jest.fn().mockResolvedValue(fetchedPage);
context.webResearchService!.fetchUrl = vi.fn().mockResolvedValue(fetchedPage);
const tool = createResearchTool(context);
await executeTool(
@ -729,24 +730,24 @@ describe('research tool', () => {
// keeps the redirect check live, which is what we want to test.
const inputHost = new URL(inputUrl).hostname;
const tracker = {
isHostAllowed: jest.fn((host: string) => host === inputHost),
approveDomain: jest.fn(),
approveAllDomains: jest.fn(),
approveOnce: jest.fn(),
clearRun: jest.fn(),
setPendingApprovalHost: jest.fn(),
consumePendingApprovalHost: jest.fn(),
isAllDomainsApproved: jest.fn().mockReturnValue(false),
isWebSearchAllowed: jest.fn().mockReturnValue(false),
approveWebSearch: jest.fn(),
approveWebSearchOnce: jest.fn(),
isHostAllowed: vi.fn((host: string) => host === inputHost),
approveDomain: vi.fn(),
approveAllDomains: vi.fn(),
approveOnce: vi.fn(),
clearRun: vi.fn(),
setPendingApprovalHost: vi.fn(),
consumePendingApprovalHost: vi.fn(),
isAllDomainsApproved: vi.fn().mockReturnValue(false),
isWebSearchAllowed: vi.fn().mockReturnValue(false),
approveWebSearch: vi.fn(),
approveWebSearchOnce: vi.fn(),
};
let captured: AuthorizeUrl | undefined;
const context = createMockContext({
domainAccessTracker: tracker as never,
permissions: {},
});
context.webResearchService!.fetchUrl = jest.fn(
context.webResearchService!.fetchUrl = vi.fn(
async (_url: string, options?: { authorizeUrl?: AuthorizeUrl }) => {
await Promise.resolve();
captured = options?.authorizeUrl;

View File

@ -1,3 +1,5 @@
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import { createToolRegistry } from '../../tool-registry';
import type { OrchestrationContext } from '../../types';
@ -14,18 +16,18 @@ function createMockContext(overrides: Partial<OrchestrationContext> = {}): Orche
modelId: 'test-model',
subAgentMaxSteps: 10,
eventBus: {
publish: jest.fn(),
subscribe: jest.fn(),
publish: vi.fn(),
subscribe: vi.fn(),
},
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() } as never,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never,
domainTools: createToolRegistry(),
abortSignal: new AbortController().signal,
taskStorage: {
get: jest.fn(),
save: jest.fn(),
get: vi.fn(),
save: vi.fn(),
},
cancelBackgroundTask: jest.fn(),
sendCorrectionToTask: jest.fn(),
cancelBackgroundTask: vi.fn(),
sendCorrectionToTask: vi.fn(),
...overrides,
} as unknown as OrchestrationContext;
}
@ -116,7 +118,7 @@ describe('task-control tool', () => {
describe('correct-task action', () => {
it('should send correction and return success message', async () => {
const context = createMockContext();
(context.sendCorrectionToTask as jest.Mock).mockReturnValue('queued');
(context.sendCorrectionToTask as Mock).mockReturnValue('queued');
const tool = createTaskControlTool(context);
const result = await executeTool(
@ -142,7 +144,7 @@ describe('task-control tool', () => {
it('should return task-not-found message when task does not exist', async () => {
const context = createMockContext();
(context.sendCorrectionToTask as jest.Mock).mockReturnValue('task-not-found');
(context.sendCorrectionToTask as Mock).mockReturnValue('task-not-found');
const tool = createTaskControlTool(context);
const result = await executeTool(
@ -162,7 +164,7 @@ describe('task-control tool', () => {
it('should return task-completed message when task has finished', async () => {
const context = createMockContext();
(context.sendCorrectionToTask as jest.Mock).mockReturnValue('task-completed');
(context.sendCorrectionToTask as Mock).mockReturnValue('task-completed');
const tool = createTaskControlTool(context);
const result = await executeTool(

View File

@ -3,7 +3,7 @@ import { createTemplatesTool } from '../templates.tool';
describe('templates tool', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
describe('best-practices action', () => {

View File

@ -1,4 +1,5 @@
import type { InstanceAiPermissions } from '@n8n/api-types';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../types';
@ -6,17 +7,17 @@ import { analyzeWorkflow, applyNodeChanges } from '../workflows/setup-workflow.s
import { createWorkflowsTool, type WorkflowAction } from '../workflows.tool';
// Mock the setup-workflow.service module to avoid pulling in heavy dependencies
jest.mock('../workflows/setup-workflow.service', () => ({
analyzeWorkflow: jest.fn().mockResolvedValue([]),
applyNodeCredentials: jest.fn().mockResolvedValue({ failed: [] }),
applyNodeParameters: jest.fn().mockResolvedValue({ failed: [] }),
applyNodeChanges: jest.fn().mockResolvedValue({ failed: [] }),
buildCompletedReport: jest.fn().mockReturnValue([]),
vi.mock('../workflows/setup-workflow.service', () => ({
analyzeWorkflow: vi.fn().mockResolvedValue([]),
applyNodeCredentials: vi.fn().mockResolvedValue({ failed: [] }),
applyNodeParameters: vi.fn().mockResolvedValue({ failed: [] }),
applyNodeChanges: vi.fn().mockResolvedValue({ failed: [] }),
buildCompletedReport: vi.fn().mockReturnValue([]),
}));
// Mock the dynamic import of @n8n/workflow-sdk used by get-as-code
jest.mock('@n8n/workflow-sdk', () => ({
generateWorkflowCode: jest.fn().mockReturnValue('// generated code'),
vi.mock('@n8n/workflow-sdk', () => ({
generateWorkflowCode: vi.fn().mockReturnValue('// generated code'),
}));
function createMockContext(
@ -27,8 +28,8 @@ function createMockContext(
return {
userId: 'user-1',
workflowService: {
list: jest.fn(),
get: jest.fn().mockResolvedValue({
list: vi.fn(),
get: vi.fn().mockResolvedValue({
id: 'wf1',
name: 'Test WF',
versionId: 'v1',
@ -39,50 +40,50 @@ function createMockContext(
nodes: [],
connections: {},
}),
getAsWorkflowJSON: jest.fn().mockResolvedValue({
getAsWorkflowJSON: vi.fn().mockResolvedValue({
name: 'Test WF',
nodes: [],
connections: {},
}),
createFromWorkflowJSON: jest.fn(),
updateFromWorkflowJSON: jest.fn(),
archive: jest.fn(),
unarchive: jest.fn(),
publish: jest.fn().mockResolvedValue({ activeVersionId: 'v1' }),
unpublish: jest.fn(),
createFromWorkflowJSON: vi.fn(),
updateFromWorkflowJSON: vi.fn(),
archive: vi.fn(),
unarchive: vi.fn(),
publish: vi.fn().mockResolvedValue({ activeVersionId: 'v1' }),
unpublish: vi.fn(),
},
executionService: {
list: jest.fn(),
run: jest.fn(),
getStatus: jest.fn(),
getResult: jest.fn(),
stop: jest.fn(),
getDebugInfo: jest.fn(),
getNodeOutput: jest.fn(),
list: vi.fn(),
run: vi.fn(),
getStatus: vi.fn(),
getResult: vi.fn(),
stop: vi.fn(),
getDebugInfo: vi.fn(),
getNodeOutput: vi.fn(),
},
credentialService: {
list: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
test: jest.fn(),
list: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
test: vi.fn(),
},
nodeService: {
listAvailable: jest.fn(),
getDescription: jest.fn(),
listSearchable: jest.fn(),
listAvailable: vi.fn(),
getDescription: vi.fn(),
listSearchable: vi.fn(),
},
dataTableService: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
getSchema: jest.fn(),
addColumn: jest.fn(),
deleteColumn: jest.fn(),
renameColumn: jest.fn(),
queryRows: jest.fn(),
insertRows: jest.fn(),
updateRows: jest.fn(),
deleteRows: jest.fn(),
list: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
getSchema: vi.fn(),
addColumn: vi.fn(),
deleteColumn: vi.fn(),
renameColumn: vi.fn(),
queryRows: vi.fn(),
insertRows: vi.fn(),
updateRows: vi.fn(),
deleteRows: vi.fn(),
},
permissions: {},
...overrides,
@ -100,7 +101,7 @@ function getDescription(tool: unknown): string {
describe('workflows tool', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
describe('surface filtering', () => {
@ -166,10 +167,10 @@ describe('workflows tool', () => {
[{ action: 'update-version', workflowId: 'w1', versionId: 'v1', name: 'v1' }],
])('should reject action %p when it is not explicitly allowed', (input) => {
const context = createMockContext();
context.workflowService.listVersions = jest.fn();
context.workflowService.getVersion = jest.fn();
context.workflowService.restoreVersion = jest.fn();
context.workflowService.updateVersion = jest.fn();
context.workflowService.listVersions = vi.fn();
context.workflowService.getVersion = vi.fn();
context.workflowService.restoreVersion = vi.fn();
context.workflowService.updateVersion = vi.fn();
const tool = createWorkflowsTool(context, {
allowedActions: builderWorkflowActions,
});
@ -194,9 +195,9 @@ describe('workflows tool', () => {
it('should support version actions when listVersions exists', async () => {
const context = createMockContext();
const versions = [{ id: 'v1', versionId: 1 }];
context.workflowService.listVersions = jest.fn().mockResolvedValue(versions);
context.workflowService.getVersion = jest.fn();
context.workflowService.restoreVersion = jest.fn();
context.workflowService.listVersions = vi.fn().mockResolvedValue(versions);
context.workflowService.getVersion = vi.fn();
context.workflowService.restoreVersion = vi.fn();
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(
@ -212,10 +213,10 @@ describe('workflows tool', () => {
const context = createMockContext({
permissions: { updateWorkflow: 'always_allow' },
});
context.workflowService.listVersions = jest.fn();
context.workflowService.getVersion = jest.fn();
context.workflowService.restoreVersion = jest.fn();
context.workflowService.updateVersion = jest.fn().mockResolvedValue({ success: true });
context.workflowService.listVersions = vi.fn();
context.workflowService.getVersion = vi.fn();
context.workflowService.restoreVersion = vi.fn();
context.workflowService.updateVersion = vi.fn().mockResolvedValue({ success: true });
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(
@ -236,7 +237,7 @@ describe('workflows tool', () => {
const context = createMockContext({
permissions: { updateWorkflow: 'blocked' },
});
context.workflowService.updateVersion = jest.fn();
context.workflowService.updateVersion = vi.fn();
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(
@ -260,8 +261,8 @@ describe('workflows tool', () => {
it('should suspend update-version for approval by default', async () => {
const context = createMockContext();
context.workflowService.updateVersion = jest.fn();
const suspend = jest.fn();
context.workflowService.updateVersion = vi.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(
@ -287,7 +288,7 @@ describe('workflows tool', () => {
it('should update-version when approval resumes approved', async () => {
const context = createMockContext();
context.workflowService.updateVersion = jest.fn().mockResolvedValue({ success: true });
context.workflowService.updateVersion = vi.fn().mockResolvedValue({ success: true });
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(
@ -310,7 +311,7 @@ describe('workflows tool', () => {
it('should not update-version when approval resumes denied', async () => {
const context = createMockContext();
context.workflowService.updateVersion = jest.fn();
context.workflowService.updateVersion = vi.fn();
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(
@ -347,7 +348,7 @@ describe('workflows tool', () => {
},
];
const context = createMockContext();
(context.workflowService.list as jest.Mock).mockResolvedValue(workflows);
(context.workflowService.list as Mock).mockResolvedValue(workflows);
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(
@ -362,7 +363,7 @@ describe('workflows tool', () => {
it('should pass archived status when listing archived workflows', async () => {
const context = createMockContext();
(context.workflowService.list as jest.Mock).mockResolvedValue([]);
(context.workflowService.list as Mock).mockResolvedValue([]);
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'list', status: 'archived' }, {} as never);
@ -372,7 +373,7 @@ describe('workflows tool', () => {
it('should pass all status when listing all workflows', async () => {
const context = createMockContext();
(context.workflowService.list as jest.Mock).mockResolvedValue([]);
(context.workflowService.list as Mock).mockResolvedValue([]);
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'list', status: 'all' }, {} as never);
@ -395,7 +396,7 @@ describe('workflows tool', () => {
updatedAt: '2024-01-01',
};
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue(detail);
(context.workflowService.get as Mock).mockResolvedValue(detail);
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(tool, { action: 'get', workflowId: 'wf1' }, {} as never);
@ -425,7 +426,7 @@ describe('workflows tool', () => {
connections: {},
};
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(workflow);
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(workflow);
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(
@ -457,11 +458,11 @@ describe('workflows tool', () => {
it('should suspend for confirmation using the looked-up workflow name', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
(context.workflowService.get as Mock).mockResolvedValue({
id: 'wf1',
name: 'My WF',
});
const suspend = jest.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'delete', workflowId: 'wf1' }, {
@ -479,8 +480,8 @@ describe('workflows tool', () => {
it('should fall back to workflowId in message when lookup fails', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockRejectedValue(new Error('not found'));
const suspend = jest.fn();
(context.workflowService.get as Mock).mockRejectedValue(new Error('not found'));
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'delete', workflowId: 'wf1' }, {
@ -545,11 +546,11 @@ describe('workflows tool', () => {
it('should suspend for confirmation using the looked-up workflow name', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
(context.workflowService.get as Mock).mockResolvedValue({
id: 'wf1',
name: 'Archived WF',
});
const suspend = jest.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'unarchive', workflowId: 'wf1' }, {
@ -567,12 +568,12 @@ describe('workflows tool', () => {
it('should return the suspension result when approval is pending', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
(context.workflowService.get as Mock).mockResolvedValue({
id: 'wf1',
name: 'Archived WF',
});
const suspension = { suspended: true };
const suspend = jest.fn().mockResolvedValue(suspension);
const suspend = vi.fn().mockResolvedValue(suspension);
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(tool, { action: 'unarchive', workflowId: 'wf1' }, {
@ -631,7 +632,7 @@ describe('workflows tool', () => {
it('should suspend for confirmation and then publish when approved', async () => {
const context = createMockContext();
(context.workflowService.publish as jest.Mock).mockResolvedValue({
(context.workflowService.publish as Mock).mockResolvedValue({
activeVersionId: 'v2',
});
@ -652,7 +653,7 @@ describe('workflows tool', () => {
it('should publish direct Execute Workflow dependencies before the main workflow', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue({
name: 'Parent',
nodes: [
{
@ -673,7 +674,7 @@ describe('workflows tool', () => {
],
connections: {},
});
(context.workflowService.publish as jest.Mock).mockResolvedValue({
(context.workflowService.publish as Mock).mockResolvedValue({
activeVersionId: 'v-main',
});
@ -697,7 +698,7 @@ describe('workflows tool', () => {
it('should roll back direct Execute Workflow dependencies when the main workflow publish fails', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue({
name: 'Parent',
nodes: [
{
@ -713,7 +714,7 @@ describe('workflows tool', () => {
],
connections: {},
});
(context.workflowService.get as jest.Mock).mockImplementation((workflowId: string) => ({
(context.workflowService.get as Mock).mockImplementation((workflowId: string) => ({
id: workflowId,
name: workflowId,
versionId: `${workflowId}-draft`,
@ -724,7 +725,7 @@ describe('workflows tool', () => {
nodes: [],
connections: {},
}));
(context.workflowService.publish as jest.Mock).mockImplementation((workflowId: string) => {
(context.workflowService.publish as Mock).mockImplementation((workflowId: string) => {
if (workflowId === 'wf1') throw new Error('Main publish failed');
return { activeVersionId: `${workflowId}-active` };
});
@ -752,11 +753,11 @@ describe('workflows tool', () => {
it('should suspend for confirmation using the looked-up workflow name', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
(context.workflowService.get as Mock).mockResolvedValue({
id: 'wf1',
name: 'My WF',
});
const suspend = jest.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'publish', workflowId: 'wf1' }, {
@ -774,11 +775,11 @@ describe('workflows tool', () => {
it('should include direct Execute Workflow dependencies in publish confirmation', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
(context.workflowService.get as Mock).mockResolvedValue({
id: 'wf1',
name: 'My WF',
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue({
name: 'Parent',
nodes: [
{
@ -789,7 +790,7 @@ describe('workflows tool', () => {
],
connections: {},
});
const suspend = jest.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'publish', workflowId: 'wf1' }, {
@ -855,10 +856,10 @@ describe('workflows tool', () => {
needsAction: true,
},
];
(analyzeWorkflow as jest.Mock).mockResolvedValue(setupRequests);
(analyzeWorkflow as Mock).mockResolvedValue(setupRequests);
const context = createMockContext();
const suspend = jest.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'setup', workflowId: 'wf1' }, {
@ -877,7 +878,7 @@ describe('workflows tool', () => {
});
it('should return success when no nodes need setup', async () => {
(analyzeWorkflow as jest.Mock).mockResolvedValue([]);
(analyzeWorkflow as Mock).mockResolvedValue([]);
const context = createMockContext();
@ -894,11 +895,11 @@ describe('workflows tool', () => {
// POST, the e2e test showed the workflow's parameter was empty after
// apply. This pins down the tool-layer contract between the resume
// payload and the service call — if this ever drifts we catch it here.
(analyzeWorkflow as jest.Mock).mockResolvedValue([]);
(applyNodeChanges as jest.Mock).mockResolvedValue({ applied: ['HTTP Request'], failed: [] });
(analyzeWorkflow as Mock).mockResolvedValue([]);
(applyNodeChanges as Mock).mockResolvedValue({ applied: ['HTTP Request'], failed: [] });
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue({
name: 'Test WF',
nodes: [
{
@ -943,11 +944,11 @@ describe('workflows tool', () => {
it('should suspend for confirmation using the looked-up workflow name', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
(context.workflowService.get as Mock).mockResolvedValue({
id: 'wf1',
name: 'My WF',
});
const suspend = jest.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'unpublish', workflowId: 'wf1' }, {

View File

@ -1,4 +1,5 @@
import type { InstanceAiPermissions } from '@n8n/api-types';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../types';
@ -12,55 +13,55 @@ function createMockContext(
return {
userId: 'user-1',
workflowService: {
list: jest.fn(),
get: jest.fn(),
getAsWorkflowJSON: jest.fn(),
createFromWorkflowJSON: jest.fn(),
updateFromWorkflowJSON: jest.fn(),
archive: jest.fn(),
delete: jest.fn(),
publish: jest.fn(),
unpublish: jest.fn(),
list: vi.fn(),
get: vi.fn(),
getAsWorkflowJSON: vi.fn(),
createFromWorkflowJSON: vi.fn(),
updateFromWorkflowJSON: vi.fn(),
archive: vi.fn(),
delete: vi.fn(),
publish: vi.fn(),
unpublish: vi.fn(),
},
executionService: {
list: jest.fn(),
run: jest.fn(),
getStatus: jest.fn(),
getResult: jest.fn(),
stop: jest.fn(),
getDebugInfo: jest.fn(),
getNodeOutput: jest.fn(),
list: vi.fn(),
run: vi.fn(),
getStatus: vi.fn(),
getResult: vi.fn(),
stop: vi.fn(),
getDebugInfo: vi.fn(),
getNodeOutput: vi.fn(),
},
credentialService: {
list: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
test: jest.fn(),
list: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
test: vi.fn(),
},
nodeService: {
listAvailable: jest.fn(),
getDescription: jest.fn(),
listSearchable: jest.fn(),
listAvailable: vi.fn(),
getDescription: vi.fn(),
listSearchable: vi.fn(),
},
dataTableService: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
getSchema: jest.fn(),
addColumn: jest.fn(),
deleteColumn: jest.fn(),
renameColumn: jest.fn(),
queryRows: jest.fn(),
insertRows: jest.fn(),
updateRows: jest.fn(),
deleteRows: jest.fn(),
list: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
getSchema: vi.fn(),
addColumn: vi.fn(),
deleteColumn: vi.fn(),
renameColumn: vi.fn(),
queryRows: vi.fn(),
insertRows: vi.fn(),
updateRows: vi.fn(),
deleteRows: vi.fn(),
},
workspaceService: {
listProjects: jest.fn(),
listTags: jest.fn(),
tagWorkflow: jest.fn(),
createTag: jest.fn(),
cleanupTestExecutions: jest.fn(),
listProjects: vi.fn(),
listTags: vi.fn(),
tagWorkflow: vi.fn(),
createTag: vi.fn(),
cleanupTestExecutions: vi.fn(),
},
permissions: {},
...overrides,
@ -83,7 +84,7 @@ describe('workspace tool', () => {
it('should call workspaceService.listProjects and return result', async () => {
const projects = [{ id: 'p1', name: 'Project 1', type: 'team' as const }];
const context = createMockContext();
(context.workspaceService!.listProjects as jest.Mock).mockResolvedValue(projects);
(context.workspaceService!.listProjects as Mock).mockResolvedValue(projects);
const tool = createWorkspaceTool(context);
const result = await executeTool(tool, { action: 'list-projects' }, {} as never);
@ -97,7 +98,7 @@ describe('workspace tool', () => {
it('should call workspaceService.listTags and return result', async () => {
const tags = [{ id: 't1', name: 'production' }];
const context = createMockContext();
(context.workspaceService!.listTags as jest.Mock).mockResolvedValue(tags);
(context.workspaceService!.listTags as Mock).mockResolvedValue(tags);
const tool = createWorkspaceTool(context);
const result = await executeTool(tool, { action: 'list-tags' }, {} as never);
@ -129,7 +130,7 @@ describe('workspace tool', () => {
it('should suspend for confirmation when permission requires approval', async () => {
const context = createMockContext();
const suspend = jest.fn();
const suspend = vi.fn();
const tool = createWorkspaceTool(context);
await executeTool(
@ -147,7 +148,7 @@ describe('workspace tool', () => {
it('should execute when approved via resume', async () => {
const context = createMockContext();
(context.workspaceService!.tagWorkflow as jest.Mock).mockResolvedValue(['prod']);
(context.workspaceService!.tagWorkflow as Mock).mockResolvedValue(['prod']);
const tool = createWorkspaceTool(context);
const result = await executeTool(
@ -181,7 +182,7 @@ describe('workspace tool', () => {
const context = createMockContext({
permissions: { tagWorkflow: 'always_allow' },
});
(context.workspaceService!.tagWorkflow as jest.Mock).mockResolvedValue(['prod']);
(context.workspaceService!.tagWorkflow as Mock).mockResolvedValue(['prod']);
const tool = createWorkspaceTool(context);
const result = await executeTool(
@ -199,10 +200,10 @@ describe('workspace tool', () => {
it('should accept folder actions when listFolders is present', async () => {
const context = createMockContext();
const folders = [{ id: 'f1', name: 'Test Folder', parentFolderId: null }];
context.workspaceService!.listFolders = jest.fn().mockResolvedValue(folders);
context.workspaceService!.createFolder = jest.fn();
context.workspaceService!.deleteFolder = jest.fn();
context.workspaceService!.moveWorkflowToFolder = jest.fn();
context.workspaceService!.listFolders = vi.fn().mockResolvedValue(folders);
context.workspaceService!.createFolder = vi.fn();
context.workspaceService!.deleteFolder = vi.fn();
context.workspaceService!.moveWorkflowToFolder = vi.fn();
const tool = createWorkspaceTool(context);
const result = await executeTool(
@ -218,12 +219,12 @@ describe('workspace tool', () => {
describe('delete-folder', () => {
it('should suspend with destructive severity for confirmation', async () => {
const context = createMockContext();
context.workspaceService!.listFolders = jest.fn();
context.workspaceService!.createFolder = jest.fn();
context.workspaceService!.deleteFolder = jest.fn();
context.workspaceService!.moveWorkflowToFolder = jest.fn();
context.workspaceService!.listFolders = vi.fn();
context.workspaceService!.createFolder = vi.fn();
context.workspaceService!.deleteFolder = vi.fn();
context.workspaceService!.moveWorkflowToFolder = vi.fn();
const suspend = jest.fn();
const suspend = vi.fn();
const tool = createWorkspaceTool(context);
await executeTool(
@ -246,11 +247,11 @@ describe('workspace tool', () => {
it('should execute deletion when approved', async () => {
const context = createMockContext();
const deleteFolder = jest.fn().mockResolvedValue(undefined);
context.workspaceService!.listFolders = jest.fn();
context.workspaceService!.createFolder = jest.fn();
const deleteFolder = vi.fn().mockResolvedValue(undefined);
context.workspaceService!.listFolders = vi.fn();
context.workspaceService!.createFolder = vi.fn();
context.workspaceService!.deleteFolder = deleteFolder;
context.workspaceService!.moveWorkflowToFolder = jest.fn();
context.workspaceService!.moveWorkflowToFolder = vi.fn();
const tool = createWorkspaceTool(context);
const result = await executeTool(
@ -269,7 +270,7 @@ describe('workspace tool', () => {
const context = createMockContext({
permissions: { cleanupTestExecutions: 'always_allow' },
});
(context.workspaceService!.cleanupTestExecutions as jest.Mock).mockResolvedValue({
(context.workspaceService!.cleanupTestExecutions as Mock).mockResolvedValue({
deletedCount: 5,
});

View File

@ -14,51 +14,51 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiCo
return {
userId: 'test-user',
workflowService: {
list: jest.fn(),
get: jest.fn(),
getAsWorkflowJSON: jest.fn(),
createFromWorkflowJSON: jest.fn(),
updateFromWorkflowJSON: jest.fn(),
archive: jest.fn(),
unarchive: jest.fn(),
publish: jest.fn(),
unpublish: jest.fn(),
clearAiTemporary: jest.fn(),
archiveIfAiTemporary: jest.fn(),
list: vi.fn(),
get: vi.fn(),
getAsWorkflowJSON: vi.fn(),
createFromWorkflowJSON: vi.fn(),
updateFromWorkflowJSON: vi.fn(),
archive: vi.fn(),
unarchive: vi.fn(),
publish: vi.fn(),
unpublish: vi.fn(),
clearAiTemporary: vi.fn(),
archiveIfAiTemporary: vi.fn(),
},
executionService: {
list: jest.fn(),
run: jest.fn(),
getStatus: jest.fn(),
getResult: jest.fn(),
stop: jest.fn(),
getDebugInfo: jest.fn(),
getNodeOutput: jest.fn(),
getResolvedNodeParameters: jest.fn(),
list: vi.fn(),
run: vi.fn(),
getStatus: vi.fn(),
getResult: vi.fn(),
stop: vi.fn(),
getDebugInfo: vi.fn(),
getNodeOutput: vi.fn(),
getResolvedNodeParameters: vi.fn(),
},
credentialService: {
list: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
test: jest.fn(),
list: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
test: vi.fn(),
},
nodeService: {
listAvailable: jest.fn(),
getDescription: jest.fn(),
listSearchable: jest.fn(),
listAvailable: vi.fn(),
getDescription: vi.fn(),
listSearchable: vi.fn(),
},
dataTableService: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
getSchema: jest.fn(),
addColumn: jest.fn(),
deleteColumn: jest.fn(),
renameColumn: jest.fn(),
queryRows: jest.fn(),
insertRows: jest.fn(),
updateRows: jest.fn(),
deleteRows: jest.fn(),
list: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
getSchema: vi.fn(),
addColumn: vi.fn(),
deleteColumn: vi.fn(),
renameColumn: vi.fn(),
queryRows: vi.fn(),
insertRows: vi.fn(),
updateRows: vi.fn(),
deleteRows: vi.fn(),
},
...overrides,
};

View File

@ -18,16 +18,19 @@ const createContext = (
describe('createEmptyEvalDataTable', () => {
it('creates a string-typed table with the requested columns', async () => {
const create = jest
const create = vi
.fn<
ReturnType<InstanceAiContext['dataTableService']['create']>,
Parameters<InstanceAiContext['dataTableService']['create']>
(
...args: Parameters<InstanceAiContext['dataTableService']['create']>
) => ReturnType<InstanceAiContext['dataTableService']['create']>
>()
.mockResolvedValue(dataTableSummary('dt-1', 'Wf — eval samples'));
const insertRows = jest.fn<
ReturnType<InstanceAiContext['dataTableService']['insertRows']>,
Parameters<InstanceAiContext['dataTableService']['insertRows']>
>();
const insertRows =
vi.fn<
(
...args: Parameters<InstanceAiContext['dataTableService']['insertRows']>
) => ReturnType<InstanceAiContext['dataTableService']['insertRows']>
>();
const ctx = createContext({ create, insertRows });
const result = await createEmptyEvalDataTable(ctx, {
@ -49,18 +52,21 @@ describe('createEmptyEvalDataTable', () => {
});
it('normalizes requested columns to valid DataTable names', async () => {
const create = jest
const create = vi
.fn<
ReturnType<InstanceAiContext['dataTableService']['create']>,
Parameters<InstanceAiContext['dataTableService']['create']>
(
...args: Parameters<InstanceAiContext['dataTableService']['create']>
) => ReturnType<InstanceAiContext['dataTableService']['create']>
>()
.mockResolvedValue(dataTableSummary('dt-1', 'Wf — eval samples'));
const ctx = createContext({
create,
insertRows: jest.fn<
ReturnType<InstanceAiContext['dataTableService']['insertRows']>,
Parameters<InstanceAiContext['dataTableService']['insertRows']>
>(),
insertRows:
vi.fn<
(
...args: Parameters<InstanceAiContext['dataTableService']['insertRows']>
) => ReturnType<InstanceAiContext['dataTableService']['insertRows']>
>(),
});
await createEmptyEvalDataTable(ctx, {
@ -80,19 +86,22 @@ describe('createEmptyEvalDataTable', () => {
});
it('retries with a nanoid suffix on name collision', async () => {
const create = jest
const create = vi
.fn<
ReturnType<InstanceAiContext['dataTableService']['create']>,
Parameters<InstanceAiContext['dataTableService']['create']>
(
...args: Parameters<InstanceAiContext['dataTableService']['create']>
) => ReturnType<InstanceAiContext['dataTableService']['create']>
>()
.mockRejectedValueOnce(new Error('Data table already exists'))
.mockResolvedValueOnce(dataTableSummary('dt-2', 'Wf — eval samples (abc12)'));
const ctx = createContext({
create,
insertRows: jest.fn<
ReturnType<InstanceAiContext['dataTableService']['insertRows']>,
Parameters<InstanceAiContext['dataTableService']['insertRows']>
>(),
insertRows:
vi.fn<
(
...args: Parameters<InstanceAiContext['dataTableService']['insertRows']>
) => ReturnType<InstanceAiContext['dataTableService']['insertRows']>
>(),
});
const result = await createEmptyEvalDataTable(ctx, {
@ -104,18 +113,21 @@ describe('createEmptyEvalDataTable', () => {
});
it('rethrows non-collision errors', async () => {
const create = jest
const create = vi
.fn<
ReturnType<InstanceAiContext['dataTableService']['create']>,
Parameters<InstanceAiContext['dataTableService']['create']>
(
...args: Parameters<InstanceAiContext['dataTableService']['create']>
) => ReturnType<InstanceAiContext['dataTableService']['create']>
>()
.mockRejectedValueOnce(new Error('database down'));
const ctx = createContext({
create,
insertRows: jest.fn<
ReturnType<InstanceAiContext['dataTableService']['insertRows']>,
Parameters<InstanceAiContext['dataTableService']['insertRows']>
>(),
insertRows:
vi.fn<
(
...args: Parameters<InstanceAiContext['dataTableService']['insertRows']>
) => ReturnType<InstanceAiContext['dataTableService']['insertRows']>
>(),
});
await expect(
createEmptyEvalDataTable(ctx, { workflowName: 'Wf', columns: ['x'] }),

View File

@ -1,14 +1,15 @@
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { Mock, MockedFunction } from 'vitest';
jest.mock('../generate-tool-ref-pin-data.service', () => ({
generateToolRefPinData: jest.fn().mockResolvedValue({}),
vi.mock('../generate-tool-ref-pin-data.service', () => ({
generateToolRefPinData: vi.fn().mockResolvedValue({}),
}));
import type { InstanceAiContext } from '../../../types';
import { createEvalsTool } from '../evals.tool';
import { generateToolRefPinData } from '../generate-tool-ref-pin-data.service';
const mockGenerateToolRefPinData = generateToolRefPinData as jest.MockedFunction<
const mockGenerateToolRefPinData = generateToolRefPinData as MockedFunction<
typeof generateToolRefPinData
>;
@ -217,33 +218,33 @@ function makeCtx(
return {
userId: 'u1',
workflowService: {
getAsWorkflowJSON: jest.fn().mockResolvedValue(wf),
updateFromWorkflowJSON: jest.fn().mockResolvedValue(undefined),
getAsWorkflowJSON: vi.fn().mockResolvedValue(wf),
updateFromWorkflowJSON: vi.fn().mockResolvedValue(undefined),
},
dataTableService: {
create: jest.fn().mockResolvedValue({
create: vi.fn().mockResolvedValue({
id: 'dt-new',
name: 'AI Flow — eval samples',
projectId: 'p-from-service',
columns: [],
}),
insertRows: jest.fn().mockResolvedValue({
insertRows: vi.fn().mockResolvedValue({
insertedCount: 0,
dataTableId: 'dt-new',
tableName: 'x',
projectId: 'p',
}),
queryRows: jest.fn().mockResolvedValue({ count: 0, data: [] }),
queryRows: vi.fn().mockResolvedValue({ count: 0, data: [] }),
...dataTableOverrides,
},
executionService: {} as never,
credentialService: {} as never,
nodeService: {} as never,
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
} as unknown as InstanceAiContext;
}
@ -251,7 +252,7 @@ function makeCtx(
// ── action: offer ──────────────────────────────────────────────────────────
describe('evalsTool — action: offer (eligibility precheck + chat message)', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('returns eligible:false with reason no-ai-nodes and never suspends for a non-AI workflow', async () => {
const wf = {
@ -270,7 +271,7 @@ describe('evalsTool — action: offer (eligibility precheck + chat message)', ()
} as unknown as WorkflowJSON;
const ctx = makeCtx(wf);
const tool = createEvalsTool(ctx);
const suspend = jest.fn();
const suspend = vi.fn();
const result = (await tool.handler!({ action: 'offer', workflowId: 'w1' }, {
agent: { suspend, resumeData: undefined },
@ -283,7 +284,7 @@ describe('evalsTool — action: offer (eligibility precheck + chat message)', ()
it('returns eligible:false with reason already-configured when EvaluationTrigger is present', async () => {
const ctx = makeCtx(evalConfiguredWf());
const tool = createEvalsTool(ctx);
const suspend = jest.fn();
const suspend = vi.fn();
const result = (await tool.handler!({ action: 'offer', workflowId: 'w1' }, {
agent: { suspend, resumeData: undefined },
@ -296,7 +297,7 @@ describe('evalsTool — action: offer (eligibility precheck + chat message)', ()
it('returns eligible:true with aiNodeNames and a chat-ready message, never suspends', async () => {
const ctx = makeCtx(aiWf());
const tool = createEvalsTool(ctx);
const suspend = jest.fn();
const suspend = vi.fn();
const result = (await tool.handler!({ action: 'offer', workflowId: 'w1' }, {
agent: { suspend, resumeData: undefined },
@ -330,7 +331,7 @@ describe('evalsTool — action: offer (eligibility precheck + chat message)', ()
// ── action: recommend-metric ───────────────────────────────────────────────
describe('evals tool — recommend-metric action', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('returns { approved: true, metricId } when user approves the recommendation', async () => {
// Agent has ai_tool connection → recommended is 'tool_use'
@ -359,7 +360,7 @@ describe('evals tool — recommend-metric action', () => {
// Plain agent (no tools / retrievers) → recommended is 'correctness'
const ctx = makeCtx(aiWf());
const tool = createEvalsTool(ctx);
const suspend = jest.fn();
const suspend = vi.fn();
await tool.handler!({ action: 'recommend-metric', workflowId: 'w1' }, {
agent: { suspend, resumeData: undefined },
@ -382,7 +383,7 @@ describe('evals tool — recommend-metric action', () => {
// ── action: select-metrics ─────────────────────────────────────────────────
describe('evals tool — select-metrics action', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('returns the workflow-default metric ids when user approves with default selections', async () => {
// Agent has ai_tool connection → defaults are ['correctness', 'tool_use']
@ -410,8 +411,8 @@ describe('evals tool — select-metrics action', () => {
};
const ctx = makeCtx(aiWfWithTools());
const tool = createEvalsTool(ctx);
const suspend = jest
.fn<Promise<void>, [SelectMetricsSuspendPayload]>()
const suspend = vi
.fn<(...args: [SelectMetricsSuspendPayload]) => Promise<void>>()
.mockResolvedValue(undefined);
await tool.handler!({ action: 'select-metrics', workflowId: 'w1' }, {
@ -530,7 +531,7 @@ describe('evals tool — select-metrics action', () => {
} as unknown as WorkflowJSON;
const ctx = makeCtx(workflow);
const tool = createEvalsTool(ctx);
const suspend = jest.fn();
const suspend = vi.fn();
await tool.handler!({ action: 'select-metrics', workflowId: 'w1' }, {
agent: { suspend, resumeData: undefined },
} as never);
@ -553,7 +554,7 @@ describe('evals tool — select-metrics action', () => {
// ── action: propose (changed) ──────────────────────────────────────────────
describe('evals tool — propose action (changed)', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('creates an EMPTY data table by default and never inserts rows', async () => {
const ctx = makeCtx(aiWf());
@ -609,7 +610,7 @@ describe('evals tool — propose action (changed)', () => {
it('uses input.projectId when the DataTable service does not return one', async () => {
const ctx = makeCtx(aiWf(), {
create: jest.fn().mockResolvedValue({
create: vi.fn().mockResolvedValue({
id: 'dt-new',
name: 'AI Flow — eval samples',
columns: [],
@ -818,7 +819,7 @@ describe('evals tool — propose action (changed)', () => {
// ── action: offer with named refs ──────────────────────────────────────────
describe('evals tool — offer with named refs', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('expands the offer message with disclosure when agent has named refs', async () => {
const ctx = makeCtx(aiWfWithNamedRef());
@ -925,7 +926,7 @@ function aiWfWithTwoToolRefs(): WorkflowJSON {
describe('evals tool — propose with tool-ref pinData', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
mockGenerateToolRefPinData.mockResolvedValue({});
});
@ -940,7 +941,7 @@ describe('evals tool — propose with tool-ref pinData', () => {
agent: {},
} as never);
const update = ctx.workflowService.updateFromWorkflowJSON as jest.Mock;
const update = ctx.workflowService.updateFromWorkflowJSON as Mock;
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(
'w1',
@ -957,7 +958,7 @@ describe('evals tool — propose with tool-ref pinData', () => {
mockGenerateToolRefPinData.mockResolvedValue({
'Telegram Trigger': [{ json: { chat_id: '42' } }],
});
const create = jest.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
const create = vi.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
const ctx = makeCtx(aiWfWithToolRef(), { create });
const tool = createEvalsTool(ctx);
@ -982,7 +983,7 @@ describe('evals tool — propose with tool-ref pinData', () => {
mockGenerateToolRefPinData.mockResolvedValue({
'Telegram Trigger': [{ json: { chat_id: '42' } }],
});
const create = jest.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
const create = vi.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
const ctx = makeCtx(aiWfWithTwoToolRefs(), { create });
const tool = createEvalsTool(ctx);
@ -1028,10 +1029,10 @@ describe('evals tool — propose with tool-ref pinData', () => {
});
describe('evals tool — propose with named refs', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('includes named-ref columns in the DataTable schema', async () => {
const create = jest
const create = vi
.fn()
.mockResolvedValue({ id: 'dt-1', name: 'Wf — eval samples', columns: [] });
const ctx = makeCtx(aiWfWithNamedRef(), { create });
@ -1049,7 +1050,7 @@ describe('evals tool — propose with named refs', () => {
});
it('combines direct $json refs and named-ref columns in DataTable', async () => {
const create = jest.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
const create = vi.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
const ctx = makeCtx(aiWfWithDirectAndNamedRef(), { create });
const tool = createEvalsTool(ctx);

View File

@ -10,13 +10,17 @@ const buildContext = (
): InstanceAiContext =>
({
executionService: {
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
list: vi
.fn<
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValue([]),
getNodeOutput: jest.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
>(),
getNodeOutput:
vi.fn<
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>(),
...executionService,
},
}) as unknown as InstanceAiContext;
@ -84,13 +88,16 @@ describe('extractRowsFromExecutionHistory', () => {
it('extracts agent-input rows from successful executions', async () => {
const ctx = buildContext({
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
.mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]),
getNodeOutput: jest
list: vi
.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]),
getNodeOutput: vi
.fn<
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>()
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'hello' }))
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'world' })),
@ -110,17 +117,20 @@ describe('extractRowsFromExecutionHistory', () => {
it('deduplicates exact-match rows from execution history', async () => {
const ctx = buildContext({
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
list: vi
.fn<
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValueOnce([
executionSummary('e1'),
executionSummary('e2'),
executionSummary('e3'),
]),
getNodeOutput: jest
getNodeOutput: vi
.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>()
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'hello' }))
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'hello' }))
@ -141,13 +151,16 @@ describe('extractRowsFromExecutionHistory', () => {
it('skips executions where the projected record is missing a required column', async () => {
const ctx = buildContext({
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
.mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]),
getNodeOutput: jest
list: vi
.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]),
getNodeOutput: vi
.fn<
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>()
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'hello', context: 'c' }))
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'world' })),
@ -166,13 +179,16 @@ describe('extractRowsFromExecutionHistory', () => {
it('coerces non-string column values to JSON strings', async () => {
const ctx = buildContext({
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
.mockResolvedValueOnce([executionSummary('e1')]),
getNodeOutput: jest
list: vi
.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValueOnce([executionSummary('e1')]),
getNodeOutput: vi
.fn<
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>()
.mockResolvedValueOnce(nodeOutput('Trigger', { payload: { nested: 1 } })),
});
@ -192,12 +208,15 @@ describe('extractRowsFromExecutionHistory', () => {
const summaries = Array.from({ length: 30 }, (_, i) => executionSummary(`e${i}`));
const outputs = summaries.map((_, i) => nodeOutput('Trigger', { user_query: `q${i}` }));
const ctx = buildContext({
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
list: vi
.fn<
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValueOnce(summaries),
getNodeOutput: jest.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
getNodeOutput: vi.fn<
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>(async () => await Promise.resolve(outputs.shift() ?? nodeOutput('Trigger', {}))),
});
@ -213,15 +232,16 @@ describe('extractRowsFromExecutionHistory', () => {
});
it('lists only successful executions', async () => {
const list = jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
const list = vi
.fn<(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>>()
.mockResolvedValue([executionSummary('e1')]);
const ctx = buildContext({
list,
getNodeOutput: jest
getNodeOutput: vi
.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>()
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 's' })),
});
@ -240,13 +260,16 @@ describe('extractRowsFromExecutionHistory', () => {
it('returns 0 rows and skips silently when getNodeOutput throws for an execution', async () => {
const ctx = buildContext({
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
.mockResolvedValueOnce([executionSummary('e1')]),
getNodeOutput: jest
list: vi
.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValueOnce([executionSummary('e1')]),
getNodeOutput: vi
.fn<
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>()
.mockRejectedValueOnce(new Error('boom')),
});
@ -265,12 +288,15 @@ describe('extractRowsFromExecutionHistory', () => {
it('extracts expected columns from agent output when expectedToActualPairs are provided', async () => {
const ctx = buildContext({
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
list: vi
.fn<
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValueOnce([executionSummary('e1')]),
getNodeOutput: jest.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
getNodeOutput: vi.fn<
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>(
async (_id, nodeName) =>
await Promise.resolve(
@ -292,12 +318,15 @@ describe('extractRowsFromExecutionHistory', () => {
it('skips execution if the agent output is missing the actualField', async () => {
const ctx = buildContext({
list: jest
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
list: vi
.fn<
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
>()
.mockResolvedValueOnce([executionSummary('e1')]),
getNodeOutput: jest.fn<
ReturnType<ExecutionService['getNodeOutput']>,
Parameters<ExecutionService['getNodeOutput']>
getNodeOutput: vi.fn<
(
...args: Parameters<ExecutionService['getNodeOutput']>
) => ReturnType<ExecutionService['getNodeOutput']>
>(
async (_id, nodeName) =>
await Promise.resolve(

View File

@ -1,9 +1,10 @@
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { Mock, MockedFunction } from 'vitest';
jest.mock('../../../utils/eval-agents', () => {
vi.mock('../../../utils/eval-agents', () => {
return {
createEvalAgent: jest.fn(),
extractText: jest.fn(),
createEvalAgent: vi.fn(),
extractText: vi.fn(),
HAIKU_MODEL: 'test-haiku-model',
};
});
@ -17,21 +18,21 @@ import {
} from '../generate-sample-rows.service';
import type { AgentContext, SampleRowFacet } from '../generate-sample-rows.service';
const mockCreateEvalAgent = createEvalAgent as jest.MockedFunction<typeof createEvalAgent>;
const mockExtractText = extractText as jest.MockedFunction<typeof extractText>;
const mockCreateEvalAgent = createEvalAgent as MockedFunction<typeof createEvalAgent>;
const mockExtractText = extractText as MockedFunction<typeof extractText>;
function setupAgentMock(responseText: string) {
const generate = jest.fn().mockResolvedValue({ messages: [] });
const generate = vi.fn().mockResolvedValue({ messages: [] });
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);
mockExtractText.mockReturnValue(responseText);
}
type GenerateMock = jest.Mock<Promise<{ messages: [] }>, [string]>;
type GenerateMock = Mock<(...args: [string]) => Promise<{ messages: [] }>>;
function createGenerateMock(): GenerateMock {
return jest.fn<Promise<{ messages: [] }>, [string]>().mockResolvedValue({ messages: [] });
return vi.fn<(arg: string) => Promise<{ messages: [] }>>().mockResolvedValue({ messages: [] });
}
function getPromptText(generate: GenerateMock): string {
@ -43,7 +44,7 @@ function getPromptText(generate: GenerateMock): string {
const WF: WorkflowJSON = { name: 'Test', nodes: [], connections: {} } as unknown as WorkflowJSON;
describe('generateSampleRows', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('returns parsed input rows from valid JSON across batches', async () => {
setupAgentMock(JSON.stringify([{ input: 'q1' }]));
@ -87,7 +88,7 @@ describe('generateSampleRows', () => {
});
it('returns single fallback row when every batch rejects', async () => {
const generate = jest.fn().mockRejectedValue(new Error('API down'));
const generate = vi.fn().mockRejectedValue(new Error('API down'));
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);
@ -135,7 +136,7 @@ const BATCH_CONTEXT: AgentContext = {
};
describe('runBatch', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('returns parsed input rows on success', async () => {
setupAgentMock(JSON.stringify([{ input: 'q1' }]));
@ -179,7 +180,7 @@ describe('runBatch', () => {
});
it('returns empty array when generate throws (does not propagate)', async () => {
const generate = jest.fn().mockRejectedValue(new Error('API down'));
const generate = vi.fn().mockRejectedValue(new Error('API down'));
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);
@ -193,7 +194,7 @@ describe('runBatch', () => {
});
it('logs and returns empty array when parsing fails', async () => {
const logger = { warn: jest.fn<undefined, [string, Record<string, unknown>?]>() };
const logger = { warn: vi.fn<(a: string, b?: Record<string, unknown>) => undefined>() };
setupAgentMock('not json');
const rows = await runBatch({
facet: BATCH_FACET,
@ -262,7 +263,7 @@ describe('runBatch', () => {
});
it('returns empty array immediately when rowCount is zero', async () => {
const generate = jest.fn();
const generate = vi.fn();
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);
@ -470,10 +471,10 @@ describe('extractAgentContext', () => {
});
describe('generateSampleRows orchestration', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('dispatches one batch per non-empty facet', async () => {
const generate = jest.fn().mockResolvedValue({ messages: [] });
const generate = vi.fn().mockResolvedValue({ messages: [] });
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);
@ -483,7 +484,7 @@ describe('generateSampleRows orchestration', () => {
});
it('skips facets that get zero rows', async () => {
const generate = jest.fn().mockResolvedValue({ messages: [] });
const generate = vi.fn().mockResolvedValue({ messages: [] });
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);
@ -500,7 +501,7 @@ describe('generateSampleRows orchestration', () => {
Promise.resolve({ messages: [] }),
Promise.resolve({ messages: [] }),
];
const generate = jest.fn().mockImplementation(async () => await responses.shift());
const generate = vi.fn().mockImplementation(async () => await responses.shift());
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);
@ -519,7 +520,7 @@ describe('generateSampleRows orchestration', () => {
});
it('uses the default rowCount of 25 when not specified', async () => {
const generate = jest.fn().mockResolvedValue({ messages: [] });
const generate = vi.fn().mockResolvedValue({ messages: [] });
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);

View File

@ -1,19 +1,20 @@
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { MockedFunction } from 'vitest';
jest.mock('../../../utils/eval-agents', () => {
const actual: object = jest.requireActual('../../../utils/eval-agents');
return { ...actual, createEvalAgent: jest.fn(), extractText: jest.fn() };
vi.mock('../../../utils/eval-agents', async () => {
const actual: object = await vi.importActual('../../../utils/eval-agents');
return { ...actual, createEvalAgent: vi.fn(), extractText: vi.fn() };
});
import { createEvalAgent, extractText } from '../../../utils/eval-agents';
import type { ToolRef } from '../detect-tool-refs.service';
import { generateToolRefPinData } from '../generate-tool-ref-pin-data.service';
const mockCreateEvalAgent = createEvalAgent as jest.MockedFunction<typeof createEvalAgent>;
const mockExtractText = extractText as jest.MockedFunction<typeof extractText>;
const mockCreateEvalAgent = createEvalAgent as MockedFunction<typeof createEvalAgent>;
const mockExtractText = extractText as MockedFunction<typeof extractText>;
function setupAgentMock(responseText: string) {
const generate = jest.fn().mockResolvedValue({ messages: [] });
const generate = vi.fn().mockResolvedValue({ messages: [] });
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);
@ -48,7 +49,7 @@ const agentNode: WorkflowJSON['nodes'][number] = {
} as WorkflowJSON['nodes'][number];
describe('generateToolRefPinData', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
it('returns {} when no refs are passed', async () => {
const result = await generateToolRefPinData({
@ -151,7 +152,7 @@ describe('generateToolRefPinData', () => {
});
it('returns {} when the LLM call throws', async () => {
const generate = jest.fn().mockRejectedValue(new Error('boom'));
const generate = vi.fn().mockRejectedValue(new Error('boom'));
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
typeof createEvalAgent
>);

View File

@ -1,5 +1,6 @@
import { GATEWAY_CONFIRMATION_REQUIRED_PREFIX } from '@n8n/api-types';
import type { McpTool, McpToolCallResult } from '@n8n/api-types';
import type { Mock, Mocked } from 'vitest';
import { executeTool } from '../../../__tests__/tool-test-utils';
import type { LocalMcpServer } from '../../../types';
@ -81,11 +82,11 @@ const GENERIC_ERROR_RESULT: McpToolCallResult = {
// Helpers
// ---------------------------------------------------------------------------
function makeMockServer(tools: McpTool[] = [SAMPLE_TOOL]): jest.Mocked<LocalMcpServer> {
function makeMockServer(tools: McpTool[] = [SAMPLE_TOOL]): Mocked<LocalMcpServer> {
return {
getAvailableTools: jest.fn().mockReturnValue(tools),
getToolsByCategory: jest.fn().mockReturnValue([]),
callTool: jest.fn(),
getAvailableTools: vi.fn().mockReturnValue(tools),
getToolsByCategory: vi.fn().mockReturnValue([]),
callTool: vi.fn(),
};
}
@ -100,10 +101,10 @@ function getExecute(server: LocalMcpServer, toolName = 'write_file') {
/** Build a ctx object with suspend/resumeData for use in execute calls. */
function makeCtx(opts: {
suspend?: jest.Mock;
suspend?: Mock;
resumeData?: Record<string, unknown> | null;
}): unknown {
return { suspend: opts.suspend ?? jest.fn(), resumeData: opts.resumeData ?? null };
return { suspend: opts.suspend ?? vi.fn(), resumeData: opts.resumeData ?? null };
}
// ---------------------------------------------------------------------------
@ -133,7 +134,7 @@ describe('createToolsFromLocalMcpServer', () => {
});
it('skips tools with invalid names', () => {
const logger = { warn: jest.fn() };
const logger = { warn: vi.fn() };
const server = makeMockServer([
{ ...SAMPLE_TOOL, name: 'bad tool' },
{ ...SAMPLE_TOOL, name: 'read_file' },
@ -153,7 +154,7 @@ describe('createToolsFromLocalMcpServer', () => {
});
it('skips tools with unsafe object key names', () => {
const logger = { warn: jest.fn() };
const logger = { warn: vi.fn() };
const server = makeMockServer([
{ ...SAMPLE_TOOL, name: 'constructor' },
{ ...SAMPLE_TOOL, name: 'read_file' },
@ -173,7 +174,7 @@ describe('createToolsFromLocalMcpServer', () => {
});
it('skips normalized name collisions between local gateway tools', () => {
const logger = { warn: jest.fn() };
const logger = { warn: vi.fn() };
const server = makeMockServer([
{ ...SAMPLE_TOOL, name: 'custom_tool' },
{ ...SAMPLE_TOOL, name: 'custom-tool' },
@ -193,7 +194,7 @@ describe('createToolsFromLocalMcpServer', () => {
});
it('skips compatibility-normalized non-ASCII tool names', () => {
const logger = { warn: jest.fn() };
const logger = { warn: vi.fn() };
const server = makeMockServer([
{ ...SAMPLE_TOOL, name: '' },
{ ...SAMPLE_TOOL, name: 'read_file' },
@ -213,7 +214,7 @@ describe('createToolsFromLocalMcpServer', () => {
});
it('skips oversized raw schemas before tool construction', () => {
const logger = { warn: jest.fn() };
const logger = { warn: vi.fn() };
const properties = Object.fromEntries(
Array.from({ length: 251 }, (_, index) => [`field_${index}`, { type: 'string' }]),
);
@ -309,7 +310,7 @@ describe('createToolsFromLocalMcpServer', () => {
it('passes through a generic error result unchanged', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(GENERIC_ERROR_RESULT);
const suspend = jest.fn();
const suspend = vi.fn();
const execute = getExecute(server);
const result = await execute({}, makeCtx({ suspend }));
@ -321,13 +322,13 @@ describe('createToolsFromLocalMcpServer', () => {
it('calls suspend() for a plain-text GATEWAY_CONFIRMATION_REQUIRED error', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(PLAIN_CONFIRMATION_ERROR);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
const execute = getExecute(server);
await execute({ filePath: 'test.ts' }, makeCtx({ suspend }));
expect(suspend).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(suspend.mock.calls[0][0]).toMatchObject({
inputType: 'resource-decision',
severity: 'warning',
@ -340,7 +341,7 @@ describe('createToolsFromLocalMcpServer', () => {
it('filters unsupported confirmation options after parsing the daemon payload', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(PLAIN_CONFIRMATION_ERROR_WITH_UNSUPPORTED_OPTION);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
const execute = getExecute(server);
await execute({ filePath: 'test.ts' }, makeCtx({ suspend }));
@ -356,13 +357,13 @@ describe('createToolsFromLocalMcpServer', () => {
it('calls suspend() for a JSON-envelope GATEWAY_CONFIRMATION_REQUIRED error', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(JSON_ENVELOPE_CONFIRMATION_ERROR);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
const execute = getExecute(server);
await execute({}, makeCtx({ suspend }));
expect(suspend).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(suspend.mock.calls[0][0]).toMatchObject({
inputType: 'resource-decision',
resourceDecision: CONFIRMATION_PAYLOAD,

View File

@ -19,7 +19,7 @@ function createContext(memory?: BuiltMemory): OrchestrationContext {
describe('sub-agent persistence', () => {
it('creates hidden parent-scoped persistence and saves the child thread', async () => {
const saveThread = jest.fn();
const saveThread = vi.fn();
const context = createContext({ saveThread } as unknown as BuiltMemory);
const persistence = await createSubAgentPersistence(context, {
@ -47,7 +47,7 @@ describe('sub-agent persistence', () => {
});
it('respects caller-provided thread and resource IDs', async () => {
const saveThread = jest.fn();
const saveThread = vi.fn();
const context = createContext({ saveThread } as unknown as BuiltMemory);
const threadId = '00000000-0000-4000-8000-000000000002';
const resourceId = createSubAgentResourceId(PARENT_THREAD_ID, 'workflow-builder');

View File

@ -6,23 +6,19 @@ import type {
PlannedTaskGraph,
PlannedTaskService,
} from '../../../types';
import type { SetupRequest } from '../../workflows/setup-workflow.schema';
import { analyzeWorkflow } from '../../workflows/setup-workflow.service';
import { createCompleteCheckpointTool } from '../complete-checkpoint.tool';
jest.mock('../../workflows/setup-workflow.service', () => ({
analyzeWorkflow: jest.fn(),
vi.mock('../../workflows/setup-workflow.service', () => ({
analyzeWorkflow: vi.fn(),
}));
const { analyzeWorkflow } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../../workflows/setup-workflow.service') as typeof import('../../workflows/setup-workflow.service');
const { createCompleteCheckpointTool } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../complete-checkpoint.tool') as typeof import('../complete-checkpoint.tool');
function makeService(overrides: Partial<PlannedTaskService> = {}): PlannedTaskService {
return {
getGraph: jest.fn().mockResolvedValue(null),
markCheckpointSucceeded: jest.fn(),
markCheckpointFailed: jest.fn(),
getGraph: vi.fn().mockResolvedValue(null),
markCheckpointSucceeded: vi.fn(),
markCheckpointFailed: vi.fn(),
...overrides,
} as unknown as PlannedTaskService;
}
@ -39,17 +35,17 @@ function makeContext(
modelId: 'model' as OrchestrationContext['modelId'],
subAgentMaxSteps: 5,
eventBus: {
publish: jest.fn(),
subscribe: jest.fn(),
getEventsAfter: jest.fn(),
getNextEventId: jest.fn(),
getEventsForRun: jest.fn().mockReturnValue([]),
getEventsForRuns: jest.fn().mockReturnValue([]),
publish: vi.fn(),
subscribe: vi.fn(),
getEventsAfter: vi.fn(),
getNextEventId: vi.fn(),
getEventsForRun: vi.fn().mockReturnValue([]),
getEventsForRuns: vi.fn().mockReturnValue([]),
},
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
domainTools: createToolRegistry(),
abortSignal: new AbortController().signal,
taskStorage: { get: jest.fn(), save: jest.fn() },
taskStorage: { get: vi.fn(), save: vi.fn() },
plannedTaskService: service,
...overrides,
};
@ -86,12 +82,12 @@ function makeSetupRequiredGraph(): PlannedTaskGraph {
describe('createCompleteCheckpointTool', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
it('marks a checkpoint succeeded via markCheckpointSucceeded', async () => {
const service = makeService({
markCheckpointSucceeded: jest
markCheckpointSucceeded: vi
.fn()
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
});
@ -114,16 +110,16 @@ describe('createCompleteCheckpointTool', () => {
it('does not mark a checkpoint succeeded while dependent workflow setup is pending', async () => {
const service = makeService({
getGraph: jest.fn().mockResolvedValue(makeSetupRequiredGraph()),
markCheckpointSucceeded: jest.fn(),
getGraph: vi.fn().mockResolvedValue(makeSetupRequiredGraph()),
markCheckpointSucceeded: vi.fn(),
});
(analyzeWorkflow as jest.Mock).mockResolvedValue([
vi.mocked(analyzeWorkflow).mockResolvedValue([
{
node: { name: 'Slack' },
node: { name: 'Slack' } as SetupRequest['node'],
credentialType: 'slackApi',
needsAction: true,
},
]);
] as SetupRequest[]);
const tool = createCompleteCheckpointTool(
makeContext(service, { domainContext: {} as OrchestrationContext['domainContext'] }),
);
@ -144,12 +140,12 @@ describe('createCompleteCheckpointTool', () => {
it('marks a setup-required checkpoint succeeded after setup has no pending action', async () => {
const service = makeService({
getGraph: jest.fn().mockResolvedValue(makeSetupRequiredGraph()),
markCheckpointSucceeded: jest
getGraph: vi.fn().mockResolvedValue(makeSetupRequiredGraph()),
markCheckpointSucceeded: vi
.fn()
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
});
(analyzeWorkflow as jest.Mock).mockResolvedValue([]);
vi.mocked(analyzeWorkflow).mockResolvedValue([]);
const tool = createCompleteCheckpointTool(
makeContext(service, { domainContext: {} as OrchestrationContext['domainContext'] }),
);
@ -169,7 +165,7 @@ describe('createCompleteCheckpointTool', () => {
it('marks a checkpoint failed via markCheckpointFailed', async () => {
const service = makeService({
markCheckpointFailed: jest
markCheckpointFailed: vi
.fn()
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
});
@ -190,7 +186,7 @@ describe('createCompleteCheckpointTool', () => {
it('forwards structured outcome to markCheckpointFailed so replans keep execution context', async () => {
const service = makeService({
markCheckpointFailed: jest
markCheckpointFailed: vi
.fn()
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
});
@ -220,7 +216,7 @@ describe('createCompleteCheckpointTool', () => {
it('returns error string (not throw) on not-found', async () => {
const not: CheckpointSettleResult = { ok: false, reason: 'not-found' };
const service = makeService({
markCheckpointSucceeded: jest.fn().mockResolvedValue(not),
markCheckpointSucceeded: vi.fn().mockResolvedValue(not),
});
const tool = createCompleteCheckpointTool(makeContext(service));
@ -237,7 +233,7 @@ describe('createCompleteCheckpointTool', () => {
actual: { kind: 'build-workflow' },
};
const service = makeService({
markCheckpointSucceeded: jest.fn().mockResolvedValue(wk),
markCheckpointSucceeded: vi.fn().mockResolvedValue(wk),
});
const tool = createCompleteCheckpointTool(makeContext(service));
@ -255,7 +251,7 @@ describe('createCompleteCheckpointTool', () => {
actual: { status: 'planned' },
};
const service = makeService({
markCheckpointSucceeded: jest.fn().mockResolvedValue(ws),
markCheckpointSucceeded: vi.fn().mockResolvedValue(ws),
});
const tool = createCompleteCheckpointTool(makeContext(service));
@ -280,7 +276,7 @@ describe('createCompleteCheckpointTool', () => {
it('defaults failed-error to result or a sensible default', async () => {
const service = makeService({
markCheckpointFailed: jest
markCheckpointFailed: vi
.fn()
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
});

View File

@ -1,14 +1,13 @@
/* eslint-disable import-x/order */
import { executeTool } from '../../../__tests__/tool-test-utils';
import { createToolRegistry } from '../../../tool-registry';
import type { OrchestrationContext, TaskStorage } from '../../../types';
import { delegateInputSchema } from '../delegate.schemas';
jest.mock('../../../stream/consume-with-hitl', () => ({ consumeStreamWithHitl: jest.fn() }));
jest.mock('../../../storage/iteration-log', () => ({ formatPreviousAttempts: jest.fn() }));
vi.mock('../../../stream/consume-with-hitl', () => ({ consumeStreamWithHitl: vi.fn() }));
vi.mock('../../../storage/iteration-log', () => ({ formatPreviousAttempts: vi.fn() }));
const { createDelegateTool } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../delegate.tool') as typeof import('../delegate.tool');
import { createDelegateTool } from '../delegate.tool';
// ---------------------------------------------------------------------------
// Helpers
@ -23,14 +22,14 @@ function createMockContext(domainTools: Record<string, unknown> = {}): Orchestra
modelId: 'test-model',
subAgentMaxSteps: 5,
eventBus: {
publish: jest.fn(),
subscribe: jest.fn(),
getEventsAfter: jest.fn(),
getNextEventId: jest.fn(),
getEventsForRun: jest.fn().mockReturnValue([]),
getEventsForRuns: jest.fn().mockReturnValue([]),
publish: vi.fn(),
subscribe: vi.fn(),
getEventsAfter: vi.fn(),
getNextEventId: vi.fn(),
getEventsForRun: vi.fn().mockReturnValue([]),
getEventsForRuns: vi.fn().mockReturnValue([]),
},
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
domainTools: createToolRegistry(
Object.entries(domainTools).map(([name, tool]) => [
name,
@ -39,8 +38,8 @@ function createMockContext(domainTools: Record<string, unknown> = {}): Orchestra
),
abortSignal: new AbortController().signal,
taskStorage: {
get: jest.fn(),
save: jest.fn(),
get: vi.fn(),
save: vi.fn(),
} as TaskStorage,
};
}

View File

@ -1,4 +1,5 @@
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { Mock } from 'vitest';
import * as sampleRowsService from '../../evals/generate-sample-rows.service';
import { createEvalDataAgentTool } from '../eval-data-agent.tool';
@ -103,22 +104,22 @@ const defaultInsertResult = {
projectId: 'proj-1',
};
const silentLogger = () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() });
const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() });
/** Default DataTable service stub. Override individual mocks per test as needed. */
function defaultDataTableService(
overrides: Partial<{
insertRows: jest.Mock;
getSchema: jest.Mock;
addColumn: jest.Mock;
queryRows: jest.Mock;
insertRows: Mock;
getSchema: Mock;
addColumn: Mock;
queryRows: Mock;
}> = {},
) {
return {
insertRows: jest.fn().mockResolvedValue(defaultInsertResult),
getSchema: jest.fn().mockResolvedValue([]),
addColumn: jest.fn().mockResolvedValue(undefined),
queryRows: jest.fn().mockResolvedValue({ count: 0, data: [] }),
insertRows: vi.fn().mockResolvedValue(defaultInsertResult),
getSchema: vi.fn().mockResolvedValue([]),
addColumn: vi.fn().mockResolvedValue(undefined),
queryRows: vi.fn().mockResolvedValue({ count: 0, data: [] }),
...overrides,
};
}
@ -126,8 +127,8 @@ function defaultDataTableService(
/** Execution service stub with no successful executions to read from. */
function emptyExecutionService() {
return {
list: jest.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([]),
getNodeOutput: jest.fn(),
list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([]),
getNodeOutput: vi.fn(),
};
}
@ -141,8 +142,8 @@ function trigInputHistoryExecutionService(count: number) {
status: 'success',
}));
return {
list: jest.fn().mockResolvedValueOnce(summaries).mockResolvedValueOnce([]),
getNodeOutput: jest.fn(
list: vi.fn().mockResolvedValueOnce(summaries).mockResolvedValueOnce([]),
getNodeOutput: vi.fn(
async (id: string) =>
await Promise.resolve({
nodeName: 'EvalTrig',
@ -164,8 +165,8 @@ function trigInputAgentOutputExecutionService(count: number) {
status: 'success',
}));
return {
list: jest.fn().mockResolvedValueOnce(summaries).mockResolvedValueOnce([]),
getNodeOutput: jest.fn(async (id: string, nodeName: string) =>
list: vi.fn().mockResolvedValueOnce(summaries).mockResolvedValueOnce([]),
getNodeOutput: vi.fn(async (id: string, nodeName: string) =>
nodeName === 'EvalTrig'
? await Promise.resolve({
nodeName,
@ -192,7 +193,7 @@ interface BuildCtxOptions {
const buildOrchestrationCtx = (opts: BuildCtxOptions = {}) => ({
domainContext: {
workflowService: {
getAsWorkflowJSON: jest.fn().mockResolvedValue(opts.workflow ?? evalWf()),
getAsWorkflowJSON: vi.fn().mockResolvedValue(opts.workflow ?? evalWf()),
},
dataTableService: opts.dataTableService ?? defaultDataTableService(),
executionService: opts.executionService ?? emptyExecutionService(),
@ -202,12 +203,12 @@ const buildOrchestrationCtx = (opts: BuildCtxOptions = {}) => ({
describe('eval-data tool', () => {
beforeEach(() => {
jest.restoreAllMocks();
vi.restoreAllMocks();
});
it('imports rows from execution history when >= 10 valid rows are available', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'user_query' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }]),
});
const ctx = buildOrchestrationCtx({
dataTableService,
@ -224,12 +225,12 @@ describe('eval-data tool', () => {
it('falls back to synthetic generation when fewer than 10 valid history rows are available', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'user_query' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }]),
});
const ctx = buildOrchestrationCtx({ dataTableService });
jest
.spyOn(sampleRowsService, 'generateSampleRows')
.mockResolvedValue(Array.from({ length: 10 }, (_, i) => ({ user_query: `gen-${i}` })));
vi.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue(
Array.from({ length: 10 }, (_, i) => ({ user_query: `gen-${i}` })),
);
const result = await runEvalDataTool(ctx, { workflowId: 'w1' });
@ -283,10 +284,10 @@ describe('eval-data tool', () => {
settings: {},
} as unknown as WorkflowJSON;
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'input' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'input' }]),
});
const ctx = buildOrchestrationCtx({ workflow: wf, dataTableService });
jest.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([{ input: 'sample' }]);
vi.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([{ input: 'sample' }]);
const result = await runEvalDataTool(ctx, { workflowId: 'w1' });
@ -300,9 +301,7 @@ describe('eval-data tool', () => {
it('populates expected_* columns from agent output in the history path', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest
.fn()
.mockResolvedValue([{ name: 'user_query' }, { name: 'expected_response' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }, { name: 'expected_response' }]),
});
const ctx = buildOrchestrationCtx({
workflow: evalWfWithMetrics(),
@ -324,12 +323,10 @@ describe('eval-data tool', () => {
it('synthetic path generates ONLY input columns and flags expected outputs for user review', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest
.fn()
.mockResolvedValue([{ name: 'user_query' }, { name: 'expected_response' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }, { name: 'expected_response' }]),
});
const ctx = buildOrchestrationCtx({ workflow: evalWfWithMetrics(), dataTableService });
const generateSpy = jest
const generateSpy = vi
.spyOn(sampleRowsService, 'generateSampleRows')
.mockResolvedValue([{ user_query: 'q' }]);
@ -351,9 +348,7 @@ describe('eval-data tool', () => {
it('does not flag user review on the history path (real outputs are ground truth)', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest
.fn()
.mockResolvedValue([{ name: 'user_query' }, { name: 'expected_response' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }, { name: 'expected_response' }]),
});
const ctx = buildOrchestrationCtx({
workflow: evalWfWithMetrics(),
@ -369,10 +364,10 @@ describe('eval-data tool', () => {
it('does not flag user review when there are no expected-output columns', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'user_query' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }]),
});
const ctx = buildOrchestrationCtx({ dataTableService });
jest.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([{ user_query: 'q' }]);
vi.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([{ user_query: 'q' }]);
const result = await runEvalDataTool(ctx, { workflowId: 'w1' });
@ -383,15 +378,15 @@ describe('eval-data tool', () => {
it('adds missing columns to the DataTable before inserting rows', async () => {
// Schema has only the input column; expected_response is missing.
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'user_query' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }]),
});
const ctx = buildOrchestrationCtx({
workflow: evalWfWithMetrics(),
dataTableService,
});
jest
.spyOn(sampleRowsService, 'generateSampleRows')
.mockResolvedValue([{ user_query: 'q', expected_response: 'r' }]);
vi.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([
{ user_query: 'q', expected_response: 'r' },
]);
await runEvalDataTool(ctx, { workflowId: 'w1' });
@ -410,17 +405,15 @@ describe('eval-data tool', () => {
it('does not add columns that already exist in the DataTable schema', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest
.fn()
.mockResolvedValue([{ name: 'user_query' }, { name: 'expected_response' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }, { name: 'expected_response' }]),
});
const ctx = buildOrchestrationCtx({
workflow: evalWfWithMetrics(),
dataTableService,
});
jest
.spyOn(sampleRowsService, 'generateSampleRows')
.mockResolvedValue([{ user_query: 'q', expected_response: 'r' }]);
vi.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([
{ user_query: 'q', expected_response: 'r' },
]);
await runEvalDataTool(ctx, { workflowId: 'w1' });
@ -430,10 +423,10 @@ describe('eval-data tool', () => {
it('forwards projectId to insertRows when present', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'user_query' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }]),
});
const ctx = buildOrchestrationCtx({ dataTableService });
jest.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([{ user_query: 'q' }]);
vi.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([{ user_query: 'q' }]);
await runEvalDataTool(ctx, { workflowId: 'w1', projectId: 'proj-1' });
@ -444,8 +437,8 @@ describe('eval-data tool', () => {
it('returns a `table` summary so the agent can recap the populated dataset to the user', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'user_query' }]),
queryRows: jest.fn().mockResolvedValue({
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }]),
queryRows: vi.fn().mockResolvedValue({
count: 2,
data: [
{
@ -457,7 +450,7 @@ describe('eval-data tool', () => {
}),
});
const ctx = buildOrchestrationCtx({ dataTableService });
jest.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([{ user_query: 'q' }]);
vi.spyOn(sampleRowsService, 'generateSampleRows').mockResolvedValue([{ user_query: 'q' }]);
const result = await runEvalDataTool(ctx, { workflowId: 'w1' });
@ -478,13 +471,13 @@ describe('eval-data tool', () => {
// 3 valid history rows — below the 10-row threshold, so the tool
// goes synthetic but should still hand the rows to the generator.
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'user_query' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }]),
});
const ctx = buildOrchestrationCtx({
dataTableService,
executionService: trigInputHistoryExecutionService(3),
});
const generateSpy = jest
const generateSpy = vi
.spyOn(sampleRowsService, 'generateSampleRows')
.mockResolvedValue([{ user_query: 'synth' }]);
@ -503,10 +496,10 @@ describe('eval-data tool', () => {
it('does not pass realExamples when no history rows are available', async () => {
const dataTableService = defaultDataTableService({
getSchema: jest.fn().mockResolvedValue([{ name: 'user_query' }]),
getSchema: vi.fn().mockResolvedValue([{ name: 'user_query' }]),
});
const ctx = buildOrchestrationCtx({ dataTableService });
const generateSpy = jest
const generateSpy = vi
.spyOn(sampleRowsService, 'generateSampleRows')
.mockResolvedValue([{ user_query: 'synth' }]);

View File

@ -1,6 +1,6 @@
import type { BuiltTool } from '@n8n/agents';
import { DEFAULT_INSTANCE_AI_PERMISSIONS } from '@n8n/api-types';
import { mock } from 'jest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { createToolRegistry } from '../../../tool-registry';
import type { InstanceAiContext, OrchestrationContext } from '../../../types';
@ -17,11 +17,11 @@ function makeContext(
): OrchestrationContext {
const domainContext = mock<InstanceAiContext>();
domainContext.permissions = { ...DEFAULT_INSTANCE_AI_PERMISSIONS, updateWorkflow };
domainContext.workflowService.get = jest
domainContext.workflowService.get = vi
.fn()
.mockResolvedValue({ id: 'w1', name: 'Test', nodes: [], connections: {}, active: false });
domainContext.workflowService.updateFromWorkflowJSON = jest.fn().mockResolvedValue(undefined);
domainContext.workflowService.updateVersion = jest.fn().mockResolvedValue(undefined);
domainContext.workflowService.updateFromWorkflowJSON = vi.fn().mockResolvedValue(undefined);
domainContext.workflowService.updateVersion = vi.fn().mockResolvedValue(undefined);
const ctx = mock<OrchestrationContext>();
ctx.domainTools = createToolRegistry([
@ -33,7 +33,7 @@ function makeContext(
ctx.runId = 'run-1';
ctx.orchestratorAgentId = 'root-agent';
ctx.modelId = 'test-model' as OrchestrationContext['modelId'];
ctx.eventBus = { publish: jest.fn() } as unknown as OrchestrationContext['eventBus'];
ctx.eventBus = { publish: vi.fn() } as unknown as OrchestrationContext['eventBus'];
ctx.tracing = undefined;
return ctx;
}
@ -49,7 +49,7 @@ describe('buildEvalSetupTools', () => {
it('allows saving workflow JSON without prompting when parent permission is require_approval', async () => {
const ctx = makeContext('require_approval');
const tools = buildEvalSetupTools(ctx);
const suspend = jest.fn();
const suspend = vi.fn();
const workflows = tools.get('workflows');
const workflow = { name: 'Eval setup', nodes: [], connections: {} };
@ -104,7 +104,7 @@ describe('buildEvalSetupTools', () => {
describe('startEvalSetupAgentTask', () => {
it('deduplicates eval setup background tasks by workflow id', () => {
const ctx = makeContext('require_approval');
ctx.spawnBackgroundTask = jest.fn().mockReturnValue({
ctx.spawnBackgroundTask = vi.fn().mockReturnValue({
status: 'started',
taskId: 'task-1',
agentId: 'agent-1',

View File

@ -1,19 +1,18 @@
import type { Mock } from 'vitest';
import { executeTool } from '../../../__tests__/tool-test-utils';
import { PlanValidationError } from '../../../planned-tasks/planned-task-service';
import { createToolRegistry } from '../../../tool-registry';
import type { OrchestrationContext, PlannedTaskService, TaskStorage } from '../../../types';
const { createPlanTool } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../plan.tool') as typeof import('../plan.tool');
import { createPlanTool } from '../plan.tool';
function makePlannedTaskService(overrides: Partial<PlannedTaskService> = {}): PlannedTaskService {
return {
createPlan: jest.fn().mockResolvedValue(undefined),
getGraph: jest.fn().mockResolvedValue(null),
approvePlan: jest.fn().mockResolvedValue(undefined),
denyPlan: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
createPlan: vi.fn().mockResolvedValue(undefined),
getGraph: vi.fn().mockResolvedValue(null),
approvePlan: vi.fn().mockResolvedValue(undefined),
denyPlan: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as PlannedTaskService;
}
@ -27,22 +26,22 @@ function createMockContext(overrides: Partial<OrchestrationContext> = {}): Orche
modelId: 'test-model' as OrchestrationContext['modelId'],
subAgentMaxSteps: 5,
eventBus: {
publish: jest.fn(),
subscribe: jest.fn(),
getEventsAfter: jest.fn(),
getNextEventId: jest.fn(),
getEventsForRun: jest.fn().mockReturnValue([]),
getEventsForRuns: jest.fn().mockReturnValue([]),
publish: vi.fn(),
subscribe: vi.fn(),
getEventsAfter: vi.fn(),
getNextEventId: vi.fn(),
getEventsForRun: vi.fn().mockReturnValue([]),
getEventsForRuns: vi.fn().mockReturnValue([]),
},
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
domainTools: createToolRegistry(),
abortSignal: new AbortController().signal,
taskStorage: {
get: jest.fn(),
save: jest.fn(),
get: vi.fn(),
save: vi.fn(),
} as TaskStorage,
plannedTaskService: makePlannedTaskService(),
schedulePlannedTasks: jest.fn().mockResolvedValue(undefined),
schedulePlannedTasks: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
@ -93,7 +92,7 @@ describe('createPlanTool — replan-only guard', () => {
currentUserMessage: 'Create a data table for users',
});
const tool = createPlanTool(context);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
const out = await executeTool(
tool,
@ -107,7 +106,9 @@ describe('createPlanTool — replan-only guard', () => {
// Reaches native suspend path.
expect(out).toBeUndefined();
const warnMock = context.logger.warn as jest.Mock<void, [string, Record<string, unknown>?]>;
const warnMock = context.logger.warn as Mock<
(...args: [string, Record<string, unknown>?]) => void
>;
const bypassCall = warnMock.mock.calls.find(
(call) => call[0] === 'create-tasks bypassing planner with skipPlannerDiscovery=true',
);
@ -131,7 +132,7 @@ describe('createPlanTool — replan-only guard', () => {
const context = createMockContext({
currentUserMessage: 'revise the plan',
plannedTaskService: makePlannedTaskService({
getGraph: jest.fn().mockResolvedValue({
getGraph: vi.fn().mockResolvedValue({
threadId: 'test-thread',
status: 'active',
tasks: [],
@ -139,7 +140,7 @@ describe('createPlanTool — replan-only guard', () => {
}),
});
const tool = createPlanTool(context);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
const out = await executeTool(tool, { tasks: validTasks() }, { suspend });
@ -154,7 +155,7 @@ describe('createPlanTool — replan-only guard', () => {
const context = createMockContext({
currentUserMessage: 'Build me a new, unrelated workflow',
plannedTaskService: makePlannedTaskService({
getGraph: jest.fn().mockResolvedValue({
getGraph: vi.fn().mockResolvedValue({
threadId: 'test-thread',
status: 'completed',
tasks: [],
@ -163,7 +164,7 @@ describe('createPlanTool — replan-only guard', () => {
});
const tool = createPlanTool(context);
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: jest.fn() });
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: vi.fn() });
expect(out.result).toMatch(/^Error: `create-tasks` is for replanning only/);
expect(context.plannedTaskService!.createPlan).not.toHaveBeenCalled();
@ -175,7 +176,7 @@ describe('createPlanTool — replan-only guard', () => {
isReplanFollowUp: true,
});
const tool = createPlanTool(context);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
const out = await executeTool(tool, { tasks: validTasks() }, { suspend });
@ -203,7 +204,7 @@ describe('createPlanTool — replan-only guard', () => {
process.env.N8N_INSTANCE_AI_ENFORCE_CREATE_TASKS_REPLAN = 'false';
const context = createMockContext({ currentUserMessage: 'ordinary initial request' });
const tool = createPlanTool(context);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
const out = await executeTool(tool, { tasks: validTasks() }, { suspend });
@ -244,8 +245,8 @@ describe('createPlanTool — replan-only guard', () => {
const context = createMockContext({
currentUserMessage: 'ordinary message',
taskStorage: {
get: jest.fn(),
save: jest.fn().mockRejectedValue(new Error('storage flake')),
get: vi.fn(),
save: vi.fn().mockRejectedValue(new Error('storage flake')),
} as TaskStorage,
});
const tool = createPlanTool(context);
@ -322,7 +323,7 @@ describe('createPlanTool — replan-only guard', () => {
currentUserMessage: 'revise the plan',
runId: 'run-1',
plannedTaskService: makePlannedTaskService({
getGraph: jest.fn().mockResolvedValue({
getGraph: vi.fn().mockResolvedValue({
threadId: 'test-thread',
status: 'awaiting_approval',
planRunId: 'run-1',
@ -331,7 +332,7 @@ describe('createPlanTool — replan-only guard', () => {
}),
});
const tool = createPlanTool(context);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
const out = await executeTool(tool, { tasks: validTasks() }, { suspend });
@ -344,7 +345,7 @@ describe('createPlanTool — replan-only guard', () => {
currentUserMessage: 'try again',
messageGroupId: 'mg-1',
plannedTaskService: makePlannedTaskService({
getGraph: jest.fn().mockResolvedValue({
getGraph: vi.fn().mockResolvedValue({
threadId: 'test-thread',
status: 'cancelled',
planRunId: 'run-prev',
@ -355,7 +356,7 @@ describe('createPlanTool — replan-only guard', () => {
});
const tool = createPlanTool(context);
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: jest.fn() });
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: vi.fn() });
expect(out.taskCount).toBe(0);
expect(out.result).toMatch(/denied a plan earlier in this turn/i);
@ -367,7 +368,7 @@ describe('createPlanTool — replan-only guard', () => {
currentUserMessage: 'new user message',
messageGroupId: 'mg-2',
plannedTaskService: makePlannedTaskService({
getGraph: jest.fn().mockResolvedValue({
getGraph: vi.fn().mockResolvedValue({
threadId: 'test-thread',
status: 'cancelled',
planRunId: 'run-prev',
@ -378,7 +379,7 @@ describe('createPlanTool — replan-only guard', () => {
isReplanFollowUp: true,
});
const tool = createPlanTool(context);
const suspend = jest.fn().mockResolvedValue(undefined);
const suspend = vi.fn().mockResolvedValue(undefined);
await executeTool(tool, { tasks: validTasks() }, { suspend });
@ -393,7 +394,7 @@ describe('createPlanTool — replan-only guard', () => {
currentUserMessage: 'unrelated new request',
runId: 'run-2',
plannedTaskService: makePlannedTaskService({
getGraph: jest.fn().mockResolvedValue({
getGraph: vi.fn().mockResolvedValue({
threadId: 'test-thread',
status: 'awaiting_approval',
planRunId: 'run-1',
@ -403,7 +404,7 @@ describe('createPlanTool — replan-only guard', () => {
});
const tool = createPlanTool(context);
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: jest.fn() });
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: vi.fn() });
expect(out.result).toMatch(/^Error: `create-tasks` is for replanning only/);
expect(context.plannedTaskService!.createPlan).not.toHaveBeenCalled();
@ -418,11 +419,11 @@ describe('createPlanTool — createPlan validation failures', () => {
const context = createMockContext({
currentUserMessage: 'replan after failure',
plannedTaskService: makePlannedTaskService({
createPlan: jest.fn().mockRejectedValue(validatorError),
createPlan: vi.fn().mockRejectedValue(validatorError),
}),
});
const tool = createPlanTool(context);
const suspend = jest.fn();
const suspend = vi.fn();
const out = await executeTool(
tool,
@ -449,7 +450,7 @@ describe('createPlanTool — createPlan validation failures', () => {
const context = createMockContext({
currentUserMessage: 'replan',
plannedTaskService: makePlannedTaskService({
createPlan: jest.fn().mockRejectedValue(storageError),
createPlan: vi.fn().mockRejectedValue(storageError),
}),
});
const tool = createPlanTool(context);
@ -459,7 +460,7 @@ describe('createPlanTool — createPlan validation failures', () => {
tool,
{ tasks: validTasks(), skipPlannerDiscovery: true, reason: 'bypass' },
{
suspend: jest.fn(),
suspend: vi.fn(),
},
),
).rejects.toBe(storageError);

View File

@ -162,8 +162,8 @@ describe('getPriorToolObservations', () => {
{ questionId: 'purpose', question: 'What should this do?', customText: 'Email me' },
],
};
const getEventsForRun = jest.fn().mockReturnValue([]);
const getEventsForRuns = jest.fn().mockReturnValue([
const getEventsForRun = vi.fn().mockReturnValue([]);
const getEventsForRuns = vi.fn().mockReturnValue([
{
type: 'tool-call',
runId: 'run-prior',
@ -189,7 +189,7 @@ describe('getPriorToolObservations', () => {
runId: 'run-current',
messageGroupId: 'message-group-1',
eventBus: {
getEventsAfter: jest.fn().mockReturnValue([
getEventsAfter: vi.fn().mockReturnValue([
{
id: 1,
event: {
@ -234,7 +234,7 @@ describe('getPriorToolObservations', () => {
threadId: 'thread-1',
runId: 'run-current',
eventBus: {
getEventsForRun: jest.fn().mockReturnValue([
getEventsForRun: vi.fn().mockReturnValue([
{
type: 'tool-result',
runId: 'run-current',
@ -259,7 +259,7 @@ describe('getPriorToolObservations', () => {
threadId: 'thread-1',
runId: 'run-current',
eventBus: {
getEventsForRun: jest.fn(() => {
getEventsForRun: vi.fn(() => {
throw new Error('storage unavailable');
}),
},
@ -275,7 +275,7 @@ describe('getRecentMessages', () => {
threadId: 't-1',
currentUserMessage: 'Build a Slack to-do agent',
memory: {
recall: jest.fn().mockResolvedValue({
recall: vi.fn().mockResolvedValue({
messages: [{ role: 'user', content: 'Build a Slack to-do agent' }],
}),
},

View File

@ -1,3 +1,5 @@
import type { Mock } from 'vitest';
import type {
OrchestrationContext,
PlannedTaskGraph,
@ -12,13 +14,13 @@ function makeContext(overrides: {
runId?: string;
}): {
context: OrchestrationContext;
clear: jest.Mock;
getGraph: jest.Mock;
clear: Mock;
getGraph: Mock;
} {
const clear = jest.fn(async () => {
const clear = vi.fn(async () => {
await Promise.resolve();
});
const getGraph = jest.fn(async () => {
const getGraph = vi.fn(async () => {
await Promise.resolve();
return overrides.graph;
});

View File

@ -4,18 +4,18 @@ import type { OrchestrationContext, TaskStorage } from '../../../types';
import type { WorkflowLoopAction } from '../../../workflow-loop/workflow-loop-state';
import { createReportVerificationVerdictTool } from '../report-verification-verdict.tool';
function createWorkflowTaskService(reportVerificationVerdict = jest.fn()) {
function createWorkflowTaskService(reportVerificationVerdict = vi.fn()) {
return {
reportBuildOutcome: jest.fn(),
reportBuildOutcome: vi.fn(),
reportVerificationVerdict,
getBuildOutcome: jest.fn(),
getWorkflowLoopState: jest.fn(),
updateBuildOutcome: jest.fn(),
getBuildOutcome: vi.fn(),
getWorkflowLoopState: vi.fn(),
updateBuildOutcome: vi.fn(),
};
}
function createReportVerificationVerdictMock(action: WorkflowLoopAction) {
const reportVerificationVerdict = jest.fn<Promise<WorkflowLoopAction>, [unknown]>();
const reportVerificationVerdict = vi.fn<(...args: [unknown]) => Promise<WorkflowLoopAction>>();
reportVerificationVerdict.mockResolvedValue(action);
return reportVerificationVerdict;
}
@ -29,19 +29,19 @@ function createMockContext(overrides: Partial<OrchestrationContext> = {}): Orche
modelId: 'test-model',
subAgentMaxSteps: 5,
eventBus: {
publish: jest.fn(),
subscribe: jest.fn(),
getEventsAfter: jest.fn(),
getNextEventId: jest.fn(),
getEventsForRun: jest.fn().mockReturnValue([]),
getEventsForRuns: jest.fn().mockReturnValue([]),
publish: vi.fn(),
subscribe: vi.fn(),
getEventsAfter: vi.fn(),
getNextEventId: vi.fn(),
getEventsForRun: vi.fn().mockReturnValue([]),
getEventsForRuns: vi.fn().mockReturnValue([]),
},
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
domainTools: createToolRegistry(),
abortSignal: new AbortController().signal,
taskStorage: {
get: jest.fn(),
save: jest.fn(),
get: vi.fn(),
save: vi.fn(),
} as TaskStorage,
...overrides,
};
@ -70,7 +70,7 @@ describe('report-verification-verdict tool', () => {
workflowId: 'wf-123',
summary: 'All good',
};
const reportVerificationVerdict = jest.fn().mockResolvedValue(doneAction);
const reportVerificationVerdict = vi.fn().mockResolvedValue(doneAction);
const context = createMockContext({
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
});
@ -91,7 +91,7 @@ describe('report-verification-verdict tool', () => {
it('returns verify guidance when action is verify', async () => {
const verifyAction: WorkflowLoopAction = { type: 'verify', workflowId: 'wf-123' };
const reportVerificationVerdict = jest.fn().mockResolvedValue(verifyAction);
const reportVerificationVerdict = vi.fn().mockResolvedValue(verifyAction);
const context = createMockContext({
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
});
@ -182,7 +182,7 @@ describe('report-verification-verdict tool', () => {
});
it('refuses repair verdict when persisted remediation is terminal', async () => {
const reportVerificationVerdict = jest.fn();
const reportVerificationVerdict = vi.fn();
const workflowTaskService = createWorkflowTaskService(reportVerificationVerdict);
workflowTaskService.getWorkflowLoopState.mockResolvedValue({
workItemId: 'wi_test1234',
@ -262,7 +262,7 @@ describe('report-verification-verdict tool', () => {
});
it('converts non-editable remediation into a terminal verdict', async () => {
const reportVerificationVerdict = jest.fn().mockResolvedValue({
const reportVerificationVerdict = vi.fn().mockResolvedValue({
type: 'blocked',
reason: 'Route to setup.',
} satisfies WorkflowLoopAction);
@ -302,7 +302,7 @@ describe('report-verification-verdict tool', () => {
workflowId: 'wf-123',
failureDetails: 'Missing connection between nodes',
};
const reportVerificationVerdict = jest.fn().mockResolvedValue(rebuildAction);
const reportVerificationVerdict = vi.fn().mockResolvedValue(rebuildAction);
const context = createMockContext({
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
});
@ -325,7 +325,7 @@ describe('report-verification-verdict tool', () => {
type: 'blocked',
reason: 'Repeated patch failure: TypeError',
};
const reportVerificationVerdict = jest.fn().mockResolvedValue(blockedAction);
const reportVerificationVerdict = vi.fn().mockResolvedValue(blockedAction);
const context = createMockContext({
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
});
@ -347,7 +347,7 @@ describe('report-verification-verdict tool', () => {
workflowId: 'wf-123',
summary: 'OK',
};
const reportVerificationVerdict = jest.fn().mockResolvedValue(doneAction);
const reportVerificationVerdict = vi.fn().mockResolvedValue(doneAction);
const context = createMockContext({
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
});

View File

@ -1,22 +1,21 @@
/* eslint-disable import-x/order */
import type { OrchestrationContext } from '../../../types';
const mockCreateDetachedSubAgentTraceContext = jest.fn<Promise<unknown>, [unknown]>();
const mockCreateDetachedSubAgentTraceContext = vi.fn<(arg: unknown) => Promise<unknown>>();
jest.mock('../../../tracing/langsmith-tracing', () => ({
vi.mock('../../../tracing/langsmith-tracing', () => ({
createDetachedSubAgentTraceContext: async (options: unknown): Promise<unknown> =>
await mockCreateDetachedSubAgentTraceContext(options),
getCurrentOtelSpanContext: jest.fn(() => undefined),
getCurrentTraceToolCallId: jest.fn(() => undefined),
mergeCurrentTraceMetadata: jest.fn(),
getCurrentOtelSpanContext: vi.fn(() => undefined),
getCurrentTraceToolCallId: vi.fn(() => undefined),
mergeCurrentTraceMetadata: vi.fn(),
}));
const { createDetachedSubAgentTraceFactory } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../tracing-utils') as typeof import('../tracing-utils');
import { createDetachedSubAgentTraceFactory } from '../tracing-utils';
describe('createDetachedSubAgentTraceFactory', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
it('propagates parent version metadata to detached sub-agent traces', async () => {
@ -30,7 +29,7 @@ describe('createDetachedSubAgentTraceFactory', () => {
userId: 'user-1',
modelId: 'model-1',
orchestratorAgentId: 'orchestrator-1',
tracingProxyConfig: { getAuthHeaders: jest.fn() },
tracingProxyConfig: { getAuthHeaders: vi.fn() },
tracing: {
projectName: 'project-1',
rootRun: { traceId: 'root-trace' },

View File

@ -1,3 +1,5 @@
import type { Mock } from 'vitest';
import { executeTool } from '../../../__tests__/tool-test-utils';
import { createToolRegistry } from '../../../tool-registry';
import type {
@ -28,9 +30,9 @@ type VerifyBuiltWorkflowOutput = {
function createContext(overrides: Partial<OrchestrationContext> = {}): OrchestrationContext {
const workflowTaskService = {
reportBuildOutcome: jest.fn(),
reportVerificationVerdict: jest.fn(),
getBuildOutcome: jest.fn().mockResolvedValue({
reportBuildOutcome: vi.fn(),
reportVerificationVerdict: vi.fn(),
getBuildOutcome: vi.fn().mockResolvedValue({
workItemId: 'wi_1',
taskId: 'task_1',
workflowId: 'wf_1',
@ -39,8 +41,8 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
needsUserInput: false,
summary: 'Built',
}),
getWorkflowLoopState: jest.fn(),
updateBuildOutcome: jest.fn(),
getWorkflowLoopState: vi.fn(),
updateBuildOutcome: vi.fn(),
};
return {
@ -52,10 +54,10 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
subAgentMaxSteps: 5,
eventBus: {} as OrchestrationContext['eventBus'],
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as OrchestrationContext['logger'],
domainTools: createToolRegistry(),
abortSignal: new AbortController().signal,
@ -64,10 +66,10 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
domainContext: {
userId: 'user_1',
workflowService: {
getAsWorkflowJSON: jest.fn().mockResolvedValue({ nodes: [] }),
getAsWorkflowJSON: vi.fn().mockResolvedValue({ nodes: [] }),
} as unknown as InstanceAiWorkflowService,
executionService: {
run: jest.fn().mockResolvedValue({
run: vi.fn().mockResolvedValue({
executionId: 'exec_1',
status: 'success',
}),
@ -75,8 +77,8 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
credentialService: {} as never,
nodeService: {} as never,
dataTableService: {
queryRows: jest.fn().mockResolvedValue({ count: 0, data: [] }),
deleteRows: jest.fn().mockResolvedValue({ deletedCount: 0 }),
queryRows: vi.fn().mockResolvedValue({ count: 0, data: [] }),
deleteRows: vi.fn().mockResolvedValue({ deletedCount: 0 }),
} as unknown as InstanceAiDataTableService,
},
...overrides,
@ -86,7 +88,7 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
describe('verify-built-workflow tool — remediation guard', () => {
it('routes mocked-credential verification failures to setup and records terminal verdict', async () => {
const context = createContext();
jest.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
vi.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
workItemId: 'wi_1',
taskId: 'task_1',
workflowId: 'wf_1',
@ -97,7 +99,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
mockedNodeNames: ['Gmail'],
summary: 'Built',
});
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
executionId: 'exec_1',
status: 'error',
error: 'Gmail credentials are mocked',
@ -116,14 +118,14 @@ describe('verify-built-workflow tool — remediation guard', () => {
verdict: 'needs_user_input',
}),
);
const reported = jest.mocked(context.workflowTaskService!.reportVerificationVerdict).mock
const reported = vi.mocked(context.workflowTaskService!.reportVerificationVerdict).mock
.calls[0]?.[0] as { remediation?: { category?: string } };
expect(reported.remediation).toMatchObject({ category: 'needs_setup' });
});
it('does not treat mocked credentials as setup when the execution error is code-fixable', async () => {
const context = createContext();
jest.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
vi.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
workItemId: 'wi_1',
taskId: 'task_1',
workflowId: 'wf_1',
@ -134,7 +136,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
mockedNodeNames: ['Slack'],
summary: 'Built',
});
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
executionId: 'exec_1',
status: 'error',
error: 'Code node failed: Cannot read properties of undefined',
@ -152,14 +154,14 @@ describe('verify-built-workflow tool — remediation guard', () => {
});
it('returns terminal remediation even when verdict persistence and telemetry fail', async () => {
const trackTelemetry = jest.fn(() => {
const trackTelemetry = vi.fn(() => {
throw new Error('telemetry unavailable');
});
const context = createContext({ trackTelemetry });
jest
.mocked(context.workflowTaskService!.reportVerificationVerdict)
.mockRejectedValue(new Error('storage unavailable'));
jest.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
vi.mocked(context.workflowTaskService!.reportVerificationVerdict).mockRejectedValue(
new Error('storage unavailable'),
);
vi.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
workItemId: 'wi_1',
taskId: 'task_1',
workflowId: 'wf_1',
@ -170,7 +172,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
mockedNodeNames: ['Gmail'],
summary: 'Built',
});
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
executionId: 'exec_1',
status: 'error',
error: 'Gmail credentials are mocked',
@ -199,7 +201,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
it('does not execute or report another verdict when the persisted guard is terminal', async () => {
const context = createContext();
jest.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
vi.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
workItemId: 'wi_1',
threadId: 'thread_1',
runId: 'run_1',
@ -232,7 +234,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
it('ignores terminal remediation from a previous run', async () => {
const context = createContext();
jest.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
vi.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
workItemId: 'wi_1',
threadId: 'thread_1',
runId: 'run_previous',
@ -258,7 +260,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
it('still verifies the second allowed post-submit repair before blocking further edits', async () => {
const context = createContext();
jest.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
vi.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
workItemId: 'wi_1',
threadId: 'thread_1',
runId: 'run_1',
@ -287,7 +289,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
it('blocks a failing verification after the second post-submit repair was already submitted', async () => {
const context = createContext();
jest.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
vi.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
workItemId: 'wi_1',
threadId: 'thread_1',
runId: 'run_1',
@ -305,7 +307,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
guidance: 'Verify the latest repair.',
}),
});
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
executionId: 'exec_1',
status: 'error',
error: 'Code node still fails',
@ -332,7 +334,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
it('returns editable remediation for generic runtime failures without terminal reporting', async () => {
const context = createContext();
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
executionId: 'exec_1',
status: 'error',
error: 'Node parameter value is invalid',
@ -360,15 +362,20 @@ interface VerifyToolContext {
workflowTaskService: WorkflowTaskService;
domainContext: {
executionService: {
run: jest.Mock<
Promise<ExecutionRunResult>,
[string, Record<string, unknown> | undefined, { timeout?: number; pinData?: unknown }]
run: Mock<
(
...args: [
string,
Record<string, unknown> | undefined,
{ timeout?: number; pinData?: unknown },
]
) => Promise<ExecutionRunResult>
>;
};
workflowService?: InstanceAiWorkflowService;
dataTableService?: InstanceAiDataTableService;
};
logger: { debug: jest.Mock; info: jest.Mock; warn: jest.Mock; error: jest.Mock };
logger: { debug: Mock; info: Mock; warn: Mock; error: Mock };
}
function makeBuildOutcome(overrides: Partial<WorkflowBuildOutcome> = {}): WorkflowBuildOutcome {
@ -399,12 +406,12 @@ function makeContext(
snapshotErrors?: Record<string, Error>;
} = {},
) {
const updateBuildOutcome = jest.fn(
const updateBuildOutcome = vi.fn(
async (_workItemId: string, _update: Partial<WorkflowBuildOutcome>) => {
await Promise.resolve();
},
);
const run = jest.fn(
const run = vi.fn(
async (
_workflowId: string,
_inputData: Record<string, unknown> | undefined,
@ -421,7 +428,7 @@ function makeContext(
* after the snapshot phase for a given table switches to `queriesAfterRun`.
*/
const snapshotDone = new Set<string>();
const queryRows = jest.fn(
const queryRows = vi.fn(
async (
dataTableId: string,
opts?: { limit?: number; offset?: number },
@ -455,13 +462,13 @@ function makeContext(
value: string | number | boolean | null;
}>;
};
const deleteRows = jest.fn(async (_dataTableId: string, _filter: DeleteRowsFilter) => {
const deleteRows = vi.fn(async (_dataTableId: string, _filter: DeleteRowsFilter) => {
await Promise.resolve();
return { deletedCount: 0, dataTableId: '', tableName: '', projectId: '' };
});
const workflowService = {
getAsWorkflowJSON: jest.fn(async () => {
getAsWorkflowJSON: vi.fn(async () => {
await Promise.resolve();
return { nodes: overrides.workflowNodes ?? [] };
}),
@ -473,21 +480,21 @@ function makeContext(
} as unknown as InstanceAiDataTableService;
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const ctx: VerifyToolContext = {
workflowTaskService: {
reportBuildOutcome: jest.fn(),
reportVerificationVerdict: jest.fn(),
getBuildOutcome: jest.fn(async () => {
reportBuildOutcome: vi.fn(),
reportVerificationVerdict: vi.fn(),
getBuildOutcome: vi.fn(async () => {
await Promise.resolve();
return outcome;
}),
getWorkflowLoopState: jest.fn(),
getWorkflowLoopState: vi.fn(),
updateBuildOutcome,
} as unknown as WorkflowTaskService,
domainContext: {

View File

@ -214,7 +214,7 @@ async function handleFetchUrl(
}
// ── Execute fetch ──────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/require-await -- must be async to match authorizeUrl signature
const authorizeUrl = async (targetUrl: string) => {
const redirectCheck = checkDomainAccess({
url: targetUrl,

View File

@ -8,8 +8,8 @@ import { resolveCredentials } from '../resolve-credentials';
import { stripStaleCredentialsFromWorkflow } from '../setup-workflow.service';
import { ensureWebhookIds } from '../submit-workflow.tool';
jest.mock('../../../workflow-builder', () => ({
parseAndValidate: jest.fn(() => ({
vi.mock('../../../workflow-builder', () => ({
parseAndValidate: vi.fn(() => ({
workflow: {
name: 'Generated workflow',
nodes: [{ name: 'Webhook', type: 'n8n-nodes-base.webhook', parameters: {} }],
@ -17,12 +17,12 @@ jest.mock('../../../workflow-builder', () => ({
},
warnings: [],
})),
partitionWarnings: jest.fn((warnings: unknown[]) => ({ errors: [], informational: warnings })),
partitionWarnings: vi.fn((warnings: unknown[]) => ({ errors: [], informational: warnings })),
}));
jest.mock('../resolve-credentials', () => ({
buildCredentialMap: jest.fn(async () => await Promise.resolve(new Map())),
resolveCredentials: jest.fn(
vi.mock('../resolve-credentials', () => ({
buildCredentialMap: vi.fn(async () => await Promise.resolve(new Map())),
resolveCredentials: vi.fn(
async () =>
await Promise.resolve({
mockedNodeNames: [],
@ -34,12 +34,12 @@ jest.mock('../resolve-credentials', () => ({
),
}));
jest.mock('../setup-workflow.service', () => ({
stripStaleCredentialsFromWorkflow: jest.fn(async () => await Promise.resolve()),
vi.mock('../setup-workflow.service', () => ({
stripStaleCredentialsFromWorkflow: vi.fn(async () => await Promise.resolve()),
}));
jest.mock('../submit-workflow.tool', () => ({
ensureWebhookIds: jest.fn(async () => await Promise.resolve()),
vi.mock('../submit-workflow.tool', () => ({
ensureWebhookIds: vi.fn(async () => await Promise.resolve()),
}));
describe('createBuildWorkflowTool', () => {
@ -53,7 +53,7 @@ describe('createBuildWorkflowTool', () => {
};
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
restoreBuildViaPlanGuard();
});
@ -66,15 +66,15 @@ describe('createBuildWorkflowTool', () => {
userId: 'user-1',
runId: 'run-1',
workflowService: {
createFromWorkflowJSON: jest.fn(),
clearAiTemporary: jest.fn(),
createFromWorkflowJSON: vi.fn(),
clearAiTemporary: vi.fn(),
},
credentialService: {},
nodeService: {},
dataTableService: {},
executionService: {},
permissions: { createWorkflow: 'always_allow' },
logger: { warn: jest.fn() },
logger: { warn: vi.fn() },
} as unknown as InstanceAiContext;
const tool = createBuildWorkflowTool(context);
@ -90,13 +90,13 @@ describe('createBuildWorkflowTool', () => {
});
it('aborts after repeated new workflow build plan-guard rejections', async () => {
const warn = jest.fn();
const warn = vi.fn();
const context = {
userId: 'user-1',
runId: 'run-1',
workflowService: {
createFromWorkflowJSON: jest.fn(),
clearAiTemporary: jest.fn(),
createFromWorkflowJSON: vi.fn(),
clearAiTemporary: vi.fn(),
},
credentialService: {},
nodeService: {},
@ -129,15 +129,15 @@ describe('createBuildWorkflowTool', () => {
userId: 'user-1',
runId: 'run-1',
workflowService: {
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
},
credentialService: {},
nodeService: {},
dataTableService: {},
executionService: {},
permissions: { createWorkflow: 'always_allow' },
logger: { warn: jest.fn() },
logger: { warn: vi.fn() },
} as unknown as InstanceAiContext;
const tool = createBuildWorkflowTool(context);
@ -155,15 +155,15 @@ describe('createBuildWorkflowTool', () => {
});
it('allows new workflow builds during post-plan follow-up repairs', async () => {
const reportBuildOutcome = jest.fn(
const reportBuildOutcome = vi.fn(
async () => await Promise.resolve({ type: 'verify' as const, workflowId: 'wf-1' }),
);
const context = {
userId: 'user-1',
runId: 'run-1',
workflowService: {
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
},
credentialService: {},
nodeService: {},
@ -180,7 +180,7 @@ describe('createBuildWorkflowTool', () => {
},
},
permissions: { createWorkflow: 'always_allow' },
logger: { warn: jest.fn() },
logger: { warn: vi.fn() },
} as unknown as InstanceAiContext;
const tool = createBuildWorkflowTool(context);
@ -206,16 +206,16 @@ describe('createBuildWorkflowTool', () => {
});
it('updates existing workflows during post-plan follow-ups without redundant approval', async () => {
const reportBuildOutcome = jest.fn(
const reportBuildOutcome = vi.fn(
async () => await Promise.resolve({ type: 'verify' as const, workflowId: 'wf-1' }),
);
const suspend = jest.fn();
const suspend = vi.fn();
const context = {
userId: 'user-1',
runId: 'run-1',
workflowService: {
updateFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
updateFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
},
credentialService: {},
nodeService: {},
@ -232,7 +232,7 @@ describe('createBuildWorkflowTool', () => {
},
},
permissions: { updateWorkflow: 'ask' },
logger: { warn: jest.fn() },
logger: { warn: vi.fn() },
} as unknown as InstanceAiContext;
const tool = createBuildWorkflowTool(context);
@ -263,18 +263,17 @@ describe('createBuildWorkflowTool', () => {
});
it('does not finalize the planned task when saving a supporting workflow', async () => {
const reportBuildOutcome = jest.fn<
Promise<{ type: 'verify'; workflowId: string }>,
[WorkflowBuildOutcome]
const reportBuildOutcome = vi.fn<
(outcome: WorkflowBuildOutcome) => Promise<{ type: 'verify'; workflowId: string }>
>(async () => await Promise.resolve({ type: 'verify', workflowId: 'wf-support' }));
const markSucceeded = jest.fn(async () => await Promise.resolve(null));
const onBuildOutcome = jest.fn();
const markSucceeded = vi.fn(async () => await Promise.resolve(null));
const onBuildOutcome = vi.fn();
const context = {
userId: 'user-1',
runId: 'run-1',
workflowService: {
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-support' })),
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-support' })),
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
},
credentialService: {},
nodeService: {},
@ -294,7 +293,7 @@ describe('createBuildWorkflowTool', () => {
onBuildOutcome,
},
permissions: { createWorkflow: 'always_allow' },
logger: { warn: jest.fn() },
logger: { warn: vi.fn() },
} as unknown as InstanceAiContext;
const tool = createBuildWorkflowTool(context);
@ -324,19 +323,22 @@ describe('createBuildWorkflowTool', () => {
});
it('reports a workflow-loop outcome when saving succeeds', async () => {
const reportBuildOutcome = jest.fn(
const reportBuildOutcome = vi.fn(
async () => await Promise.resolve({ type: 'verify' as const, workflowId: 'wf-1' }),
);
const markSucceeded = jest.fn<
Promise<null>,
[string, string, { result?: string; outcome?: WorkflowBuildOutcome }]
const markSucceeded = vi.fn<
(
threadId: string,
taskId: string,
update: { result?: string; outcome?: WorkflowBuildOutcome },
) => Promise<null>
>(async () => await Promise.resolve(null));
const context = {
userId: 'user-1',
runId: 'run-1',
workflowService: {
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
},
credentialService: {},
nodeService: {},
@ -355,7 +357,7 @@ describe('createBuildWorkflowTool', () => {
},
},
permissions: { createWorkflow: 'always_allow' },
logger: { warn: jest.fn() },
logger: { warn: vi.fn() },
} as unknown as InstanceAiContext;
const tool = createBuildWorkflowTool(context);
@ -395,13 +397,13 @@ describe('createBuildWorkflowTool', () => {
});
it('keeps the build successful when main workflow promotion fails', async () => {
const warn = jest.fn();
const warn = vi.fn();
const context = {
userId: 'user-1',
runId: 'run-1',
workflowService: {
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: jest.fn(async () => {
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
clearAiTemporary: vi.fn(async () => {
await Promise.resolve();
throw new Error('temporary marker cleanup failed');
}),
@ -416,12 +418,12 @@ describe('createBuildWorkflowTool', () => {
taskId: 'task-1',
workItemId: 'wi-1',
workflowTaskService: {
reportBuildOutcome: jest.fn(
reportBuildOutcome: vi.fn(
async () => await Promise.resolve({ type: 'verify' as const, workflowId: 'wf-1' }),
),
},
plannedTaskService: {
markSucceeded: jest.fn(async () => await Promise.resolve(null)),
markSucceeded: vi.fn(async () => await Promise.resolve(null)),
},
},
permissions: { createWorkflow: 'always_allow' },

View File

@ -1,4 +1,5 @@
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { Mock } from 'vitest';
import type { InstanceAiContext } from '../../../types';
import {
@ -16,16 +17,16 @@ function createMockContext(existingWorkflow?: WorkflowJSON): InstanceAiContext {
return {
userId: 'test-user',
workflowService: {
getAsWorkflowJSON: jest
getAsWorkflowJSON: vi
.fn()
.mockResolvedValue(existingWorkflow ?? { name: 'existing', nodes: [], connections: {} }),
} as unknown as InstanceAiContext['workflowService'],
executionService: {} as InstanceAiContext['executionService'],
credentialService: {
list: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
test: jest.fn(),
list: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
test: vi.fn(),
},
nodeService: {} as InstanceAiContext['nodeService'],
dataTableService: {} as InstanceAiContext['dataTableService'],
@ -305,7 +306,7 @@ describe('resolveCredentials', () => {
it('keeps a raw credential id from a type with multiple available credentials', async () => {
const ctx = createMockContext();
(ctx.credentialService.list as jest.Mock).mockResolvedValueOnce([
(ctx.credentialService.list as Mock).mockResolvedValueOnce([
{ id: 'slack-1', name: 'Team Slack', type: 'slackApi' },
{ id: 'slack-2', name: 'Backup Slack', type: 'slackApi' },
]);

View File

@ -1,4 +1,5 @@
import type { WorkflowJSON, NodeJSON } from '@n8n/workflow-sdk';
import type { Mock } from 'vitest';
import type { InstanceAiContext } from '../../../types';
import {
@ -19,51 +20,51 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiCo
return {
userId: 'test-user',
workflowService: {
list: jest.fn(),
get: jest.fn(),
getAsWorkflowJSON: jest.fn(),
createFromWorkflowJSON: jest.fn(),
updateFromWorkflowJSON: jest.fn(),
archive: jest.fn(),
unarchive: jest.fn(),
publish: jest.fn(),
unpublish: jest.fn(),
clearAiTemporary: jest.fn(),
archiveIfAiTemporary: jest.fn(),
list: vi.fn(),
get: vi.fn(),
getAsWorkflowJSON: vi.fn(),
createFromWorkflowJSON: vi.fn(),
updateFromWorkflowJSON: vi.fn(),
archive: vi.fn(),
unarchive: vi.fn(),
publish: vi.fn(),
unpublish: vi.fn(),
clearAiTemporary: vi.fn(),
archiveIfAiTemporary: vi.fn(),
},
executionService: {
list: jest.fn(),
run: jest.fn(),
getStatus: jest.fn(),
getResult: jest.fn(),
stop: jest.fn(),
getDebugInfo: jest.fn(),
getNodeOutput: jest.fn(),
getResolvedNodeParameters: jest.fn(),
list: vi.fn(),
run: vi.fn(),
getStatus: vi.fn(),
getResult: vi.fn(),
stop: vi.fn(),
getDebugInfo: vi.fn(),
getNodeOutput: vi.fn(),
getResolvedNodeParameters: vi.fn(),
},
credentialService: {
list: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
test: jest.fn(),
list: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
test: vi.fn(),
},
nodeService: {
listAvailable: jest.fn(),
getDescription: jest.fn(),
listSearchable: jest.fn(),
listAvailable: vi.fn(),
getDescription: vi.fn(),
listSearchable: vi.fn(),
},
dataTableService: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
getSchema: jest.fn(),
addColumn: jest.fn(),
deleteColumn: jest.fn(),
renameColumn: jest.fn(),
queryRows: jest.fn(),
insertRows: jest.fn(),
updateRows: jest.fn(),
deleteRows: jest.fn(),
list: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
getSchema: vi.fn(),
addColumn: vi.fn(),
deleteColumn: vi.fn(),
renameColumn: vi.fn(),
queryRows: vi.fn(),
insertRows: vi.fn(),
updateRows: vi.fn(),
deleteRows: vi.fn(),
},
...overrides,
};
@ -97,12 +98,12 @@ describe('buildSetupRequests', () => {
beforeEach(() => {
context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [{ name: 'slackApi' }],
});
// Default: credential test passes (override in specific tests for failure cases)
(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
(context.credentialService.test as Mock).mockResolvedValue({ success: true });
});
it('skips disabled nodes', async () => {
@ -118,7 +119,7 @@ describe('buildSetupRequests', () => {
});
it('detects credential types from node description', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
@ -134,10 +135,10 @@ describe('buildSetupRequests', () => {
// Simulate production: getNodeCredentialTypes is available but returns []
// (e.g. node lookup miss in the adapter). The fallback should still detect
// credentials from the node description.
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
.fn()
.mockResolvedValue([]);
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode();
const result = await buildSetupRequests(context, node);
@ -148,10 +149,10 @@ describe('buildSetupRequests', () => {
});
it('falls back to node description credentials when getNodeCredentialTypes throws', async () => {
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
.fn()
.mockRejectedValue(new Error('Node lookup failed'));
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode();
const result = await buildSetupRequests(context, node);
@ -162,16 +163,16 @@ describe('buildSetupRequests', () => {
});
it('excludes credentials whose displayOptions do not match current parameters', async () => {
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
.fn()
.mockResolvedValue([]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [
{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode({ type: 'n8n-nodes-base.httpRequest', typeVersion: 4.4 });
const result = await buildSetupRequests(context, node);
@ -181,16 +182,16 @@ describe('buildSetupRequests', () => {
});
it('includes credentials whose displayOptions match current parameters', async () => {
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
.fn()
.mockResolvedValue([]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [
{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode({
type: 'n8n-nodes-base.httpRequest',
@ -203,16 +204,16 @@ describe('buildSetupRequests', () => {
});
it('resolves dynamic credential from genericAuthType parameter', async () => {
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
.fn()
.mockResolvedValue([]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [
{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode({
type: 'n8n-nodes-base.httpRequest',
@ -231,14 +232,14 @@ describe('buildSetupRequests', () => {
});
it('resolves dynamic credential from predefinedCredentialType parameter', async () => {
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
.fn()
.mockResolvedValue([]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode({
type: 'n8n-nodes-base.httpRequest',
@ -257,7 +258,7 @@ describe('buildSetupRequests', () => {
});
it('sets needsAction=true when no credential is set', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
@ -268,10 +269,10 @@ describe('buildSetupRequests', () => {
});
it('sets needsAction=false when credential is set and test passes', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
(context.credentialService.test as jest.Mock).mockResolvedValue({
(context.credentialService.test as Mock).mockResolvedValue({
success: true,
});
@ -284,10 +285,10 @@ describe('buildSetupRequests', () => {
});
it('sets needsAction=true when credential test fails', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
(context.credentialService.test as jest.Mock).mockResolvedValue({
(context.credentialService.test as Mock).mockResolvedValue({
success: false,
message: 'Invalid token',
});
@ -301,12 +302,12 @@ describe('buildSetupRequests', () => {
});
it('sets needsAction=true when parameter issues exist', async () => {
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
properties: [{ name: 'resource', displayName: 'Resource', type: 'string' }],
});
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
.fn()
.mockResolvedValue({
resource: ['Parameter "resource" is required'],
@ -321,7 +322,7 @@ describe('buildSetupRequests', () => {
});
it('auto-applies the only credential when node has none', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
@ -333,7 +334,7 @@ describe('buildSetupRequests', () => {
});
it('does not auto-apply when multiple credentials of the same type exist', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-2', name: 'Newer Slack', updatedAt: '2025-06-01T00:00:00.000Z' },
{ id: 'cred-1', name: 'Older Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
@ -350,7 +351,7 @@ describe('buildSetupRequests', () => {
});
it('sets isAutoApplied=false when node already has credential', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
@ -363,7 +364,7 @@ describe('buildSetupRequests', () => {
});
it('uses credential cache to avoid duplicate fetches', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
@ -379,7 +380,7 @@ describe('buildSetupRequests', () => {
});
it('forwards workflowId to credentialService.list so candidates match save-time scope', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode();
await buildSetupRequests(context, node, undefined, undefined, 'wf-1');
@ -391,7 +392,7 @@ describe('buildSetupRequests', () => {
});
it('omits workflowId from credentialService.list when not provided', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode();
await buildSetupRequests(context, node);
@ -400,7 +401,7 @@ describe('buildSetupRequests', () => {
});
it('cache discriminates by workflowId so a shared cache stays correct across workflows', async () => {
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const cache = createCredentialCache();
const node = makeNode();
@ -418,10 +419,10 @@ describe('buildSetupRequests', () => {
});
it('does not generate credential request for HTTP Request with auth=none and stale node.credentials', async () => {
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
.fn()
.mockResolvedValue([]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [
{
@ -430,7 +431,7 @@ describe('buildSetupRequests', () => {
},
],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode({
name: 'HTTP Request',
@ -447,7 +448,7 @@ describe('buildSetupRequests', () => {
it('fallback: displayOptions filtering takes priority over stale node.credentials', async () => {
// Remove getNodeCredentialTypes to force fallback path
delete (context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes;
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [
{
@ -456,7 +457,7 @@ describe('buildSetupRequests', () => {
},
],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode({
name: 'HTTP Request',
@ -471,14 +472,14 @@ describe('buildSetupRequests', () => {
});
it('fallback: node with assigned credentials matching description is still detected', async () => {
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
.fn()
.mockResolvedValue([]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [{ name: 'slackApi' }],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
@ -493,11 +494,11 @@ describe('buildSetupRequests', () => {
it('fallback: node.credentials with types not in description are excluded', async () => {
// Remove getNodeCredentialTypes to force fallback path
delete (context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes;
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [{ name: 'slackApi' }],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
@ -514,7 +515,7 @@ describe('buildSetupRequests', () => {
});
it('treats placeholder values as parameter issues', async () => {
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
properties: [{ name: 'email', displayName: 'Email', type: 'string' }],
@ -533,12 +534,12 @@ describe('buildSetupRequests', () => {
});
it('adds placeholder issue even when param already has validation issues', async () => {
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
properties: [{ name: 'email', displayName: 'Email', type: 'string', required: true }],
});
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
.fn()
.mockResolvedValue({ email: ['Parameter "Email" is required'] });
@ -571,10 +572,10 @@ describe('analyzeWorkflow', () => {
});
it('returns empty array for workflow with no actionable nodes', async () => {
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([makeNode({ name: 'NoOp', type: 'n8n-nodes-base.noOp' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
});
@ -584,14 +585,14 @@ describe('analyzeWorkflow', () => {
});
it('includes nodes with credential types', async () => {
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([makeNode()]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [{ name: 'slackApi' }],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const result = await analyzeWorkflow(context, 'wf-1');
expect(result).toHaveLength(1);
@ -602,17 +603,15 @@ describe('analyzeWorkflow', () => {
const node = makeNode({
credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
makeWorkflowJSON([node]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [{ name: 'slackApi' }],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
(context.credentialService.test as Mock).mockResolvedValue({ success: true });
const result = await analyzeWorkflow(context, 'wf-1');
@ -623,17 +622,15 @@ describe('analyzeWorkflow', () => {
const node = makeNode({
credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
makeWorkflowJSON([node]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [{ name: 'slackApi' }],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
(context.credentialService.test as jest.Mock).mockResolvedValue({
(context.credentialService.test as Mock).mockResolvedValue({
success: false,
message: 'Invalid token',
});
@ -651,18 +648,18 @@ describe('analyzeWorkflow', () => {
id: 'n-trigger',
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'My Auth' } },
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([trigger]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: ['trigger'],
credentials: [{ name: 'httpHeaderAuth' }],
webhooks: [{}],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Auth', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
(context.credentialService.test as Mock).mockResolvedValue({ success: true });
const result = await analyzeWorkflow(context, 'wf-1');
@ -676,23 +673,21 @@ describe('analyzeWorkflow', () => {
const node = makeNode({
credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
makeWorkflowJSON([node]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [{ name: 'slackApi' }],
properties: [{ name: 'resource', displayName: 'Resource', type: 'string' }],
});
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
.fn()
.mockResolvedValue({
resource: ['Parameter "resource" is required'],
});
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
]);
(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
(context.credentialService.test as Mock).mockResolvedValue({ success: true });
const result = await analyzeWorkflow(context, 'wf-1');
@ -714,12 +709,12 @@ describe('analyzeWorkflow', () => {
id: 'n-action',
position: [400, 100] as [number, number],
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([action, trigger], {
Webhook: { main: [[{ node: 'Slack', type: 'main', index: 0 }]] },
}),
);
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
if (type === 'n8n-nodes-base.webhook') {
return await Promise.resolve({
group: ['trigger'],
@ -729,7 +724,7 @@ describe('analyzeWorkflow', () => {
}
return await Promise.resolve({ group: [], credentials: [{ name: 'slackApi' }] });
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const result = await analyzeWorkflow(context, 'wf-1');
@ -758,7 +753,7 @@ describe('analyzeWorkflow', () => {
typeVersion: 1,
id: 'memory-1',
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([agent, model, memory], {
'OpenAI Model': {
ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]],
@ -766,7 +761,7 @@ describe('analyzeWorkflow', () => {
Memory: { ai_memory: [[{ node: 'Agent', type: 'ai_memory', index: 0 }]] },
}),
);
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
return await Promise.resolve({
group: [],
@ -778,7 +773,7 @@ describe('analyzeWorkflow', () => {
}
return await Promise.resolve({ group: [], credentials: [] });
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const result = await analyzeWorkflow(context, 'wf-1');
@ -810,7 +805,7 @@ describe('analyzeWorkflow', () => {
typeVersion: 1,
id: 'sub-model-1',
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([agent, tool, subModel], {
Tool: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] },
'Sub Model': {
@ -818,7 +813,7 @@ describe('analyzeWorkflow', () => {
},
}),
);
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
return await Promise.resolve({
group: [],
@ -827,7 +822,7 @@ describe('analyzeWorkflow', () => {
}
return await Promise.resolve({ group: [], credentials: [] });
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const result = await analyzeWorkflow(context, 'wf-1');
@ -848,12 +843,12 @@ describe('analyzeWorkflow', () => {
typeVersion: 1,
id: 'model-1',
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([agent, model], {
Model: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] },
}),
);
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
return await Promise.resolve({
group: [],
@ -863,7 +858,7 @@ describe('analyzeWorkflow', () => {
// Agent itself returns no credentials → no setup request for it.
return await Promise.resolve({ group: [], credentials: [] });
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const result = await analyzeWorkflow(context, 'wf-1');
@ -904,7 +899,7 @@ describe('analyzeWorkflow', () => {
typeVersion: 1,
id: 'shared-1',
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([trigger, agentA, agentB, sharedModel], {
Trigger: {
main: [
@ -924,7 +919,7 @@ describe('analyzeWorkflow', () => {
},
}),
);
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
if (type === 'n8n-nodes-base.webhook') {
return await Promise.resolve({
group: ['trigger'],
@ -940,7 +935,7 @@ describe('analyzeWorkflow', () => {
}
return await Promise.resolve({ group: [], credentials: [] });
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const result = await analyzeWorkflow(context, 'wf-1');
@ -962,12 +957,12 @@ describe('analyzeWorkflow', () => {
id: 'http-1',
position: [200, 0] as [number, number],
});
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflowJSON([trigger, httpAction], {
Trigger: { main: [[{ node: 'HTTP', type: 'main', index: 0 }]] },
}),
);
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
if (type === 'n8n-nodes-base.webhook') {
return await Promise.resolve({
group: ['trigger'],
@ -980,7 +975,7 @@ describe('analyzeWorkflow', () => {
credentials: [{ name: 'httpBasicAuth' }],
});
});
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const result = await analyzeWorkflow(context, 'wf-1');
@ -1006,11 +1001,11 @@ describe('applyNodeChanges', () => {
makeNode({ name: 'Slack', id: 'n1' }),
makeNode({ name: 'Gmail', id: 'n2', type: 'n8n-nodes-base.gmail' }),
]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.credentialService.get as jest.Mock).mockImplementation(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.credentialService.get as Mock).mockImplementation(
async (id: string) => await Promise.resolve({ id, name: `Cred ${id}` }),
);
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
const result = await applyNodeChanges(
context,
@ -1028,9 +1023,9 @@ describe('applyNodeChanges', () => {
it('reports failures when credential is not found', async () => {
const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.credentialService.get as jest.Mock).mockResolvedValue(undefined);
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.credentialService.get as Mock).mockResolvedValue(undefined);
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
const result = await applyNodeChanges(context, 'wf-1', {
Slack: { slackApi: 'nonexistent' },
@ -1042,12 +1037,12 @@ describe('applyNodeChanges', () => {
it('rolls back applied nodes on save failure', async () => {
const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.credentialService.get as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.credentialService.get as Mock).mockResolvedValue({
id: 'cred-1',
name: 'My Slack',
});
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockRejectedValue(
(context.workflowService.updateFromWorkflowJSON as Mock).mockRejectedValue(
new Error('DB error'),
);
@ -1069,8 +1064,8 @@ describe('applyNodeChanges', () => {
credentials: { httpHeaderAuth: { id: 'stale', name: 'Stale Header Auth' } },
});
const wfJson = makeWorkflowJSON([node]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [
{
@ -1079,11 +1074,11 @@ describe('applyNodeChanges', () => {
},
],
});
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
await applyNodeChanges(context, 'wf-1');
const calls = (context.workflowService.updateFromWorkflowJSON as jest.Mock).mock.calls as Array<
const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
[string, WorkflowJSON]
>;
const savedJson = calls[0][1];
@ -1099,22 +1094,22 @@ describe('applyNodeChanges', () => {
parameters: { authentication: 'none' },
});
const wfJson = makeWorkflowJSON([node]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
});
(context.credentialService.get as jest.Mock).mockResolvedValue({
(context.credentialService.get as Mock).mockResolvedValue({
id: 'cred-1',
name: 'My Header Auth',
});
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
await applyNodeChanges(context, 'wf-1', {
'HTTP Request': { httpHeaderAuth: 'cred-1' },
});
const calls = (context.workflowService.updateFromWorkflowJSON as jest.Mock).mock.calls as Array<
const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
[string, WorkflowJSON]
>;
const savedJson = calls[0][1];
@ -1134,12 +1129,12 @@ describe('applyNodeChanges', () => {
parameters: { method: 'GET', url: '', authentication: 'none' },
});
const wfJson = makeWorkflowJSON([node]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
});
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
const result = await applyNodeChanges(context, 'wf-1', undefined, {
'HTTP Request': { url: 'https://example.com/api' },
@ -1148,7 +1143,7 @@ describe('applyNodeChanges', () => {
expect(result.applied).toContain('HTTP Request');
expect(result.failed).toHaveLength(0);
const calls = (context.workflowService.updateFromWorkflowJSON as jest.Mock).mock.calls as Array<
const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
[string, WorkflowJSON]
>;
expect(calls).toHaveLength(1);
@ -1172,16 +1167,16 @@ describe('applyNodeChanges', () => {
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Header Auth' } },
});
const wfJson = makeWorkflowJSON([node]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
});
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
await applyNodeChanges(context, 'wf-1');
const calls = (context.workflowService.updateFromWorkflowJSON as jest.Mock).mock.calls as Array<
const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
[string, WorkflowJSON]
>;
const savedJson = calls[0][1];
@ -1247,7 +1242,7 @@ describe('stripStaleCredentialsFromWorkflow', () => {
credentials: { httpHeaderAuth: { id: 'stale', name: 'Stale Header Auth' } },
});
const wfJson = makeWorkflowJSON([node]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [
{
@ -1274,7 +1269,7 @@ describe('stripStaleCredentialsFromWorkflow', () => {
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Header Auth' } },
});
const wfJson = makeWorkflowJSON([node]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
});
@ -1306,7 +1301,7 @@ describe('stripStaleCredentialsFromWorkflow', () => {
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'OpenRouter Auth' } },
});
const wfJson = makeWorkflowJSON([cleanNode, staleNode]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [
{
@ -1329,7 +1324,7 @@ describe('stripStaleCredentialsFromWorkflow', () => {
parameters: { authentication: 'none' },
});
const wfJson = makeWorkflowJSON([node]);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
(context.nodeService.getDescription as Mock).mockResolvedValue({
group: [],
credentials: [],
});
@ -1361,8 +1356,8 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
it('applies a credential the user is allowed to read', async () => {
const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
const wfJson = makeWorkflowJSON([node]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.credentialService.get as jest.Mock).mockResolvedValue({
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.credentialService.get as Mock).mockResolvedValue({
id: 'cred-mine',
name: 'My Slack',
type: 'slackApi',
@ -1382,8 +1377,8 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
it('does not write a credential the user cannot access', async () => {
const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
const wfJson = makeWorkflowJSON([node]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.credentialService.get as jest.Mock).mockRejectedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.credentialService.get as Mock).mockRejectedValue(
new Error('Credential with ID "cred-other" could not be found.'),
);
@ -1402,8 +1397,8 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
const slack = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
const github = makeNode({ name: 'GitHub', type: 'n8n-nodes-base.github', id: 'node-2' });
const wfJson = makeWorkflowJSON([slack, github]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.credentialService.get as jest.Mock).mockImplementation(async (credId: string) => {
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.credentialService.get as Mock).mockImplementation(async (credId: string) => {
if (credId === 'cred-mine') {
return await Promise.resolve({ id: 'cred-mine', name: 'My Slack', type: 'slackApi' });
}
@ -1425,8 +1420,8 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
it('marks a node as failed when any of its credentials are rejected', async () => {
const node = makeNode({ name: 'HTTP', type: 'n8n-nodes-base.httpRequest' });
const wfJson = makeWorkflowJSON([node]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.credentialService.get as jest.Mock).mockImplementation(async (credId: string) => {
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
(context.credentialService.get as Mock).mockImplementation(async (credId: string) => {
if (credId === 'cred-mine') {
return await Promise.resolve({ id: 'cred-mine', name: 'Auth', type: 'httpHeaderAuth' });
}
@ -1448,7 +1443,7 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
it('skips credentials for nodes not present in the workflow', async () => {
const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
const wfJson = makeWorkflowJSON([node]);
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
const result = await applyNodeCredentials(context, 'wf-1', {
GhostNode: { slackApi: 'cred-other' },

View File

@ -211,11 +211,11 @@ describe('wrapSubmitExecuteWithIdentity', () => {
});
it('blocks submit when persisted remediation says editing must stop', async () => {
const execute = jest.fn(async (): Promise<SubmitWorkflowOutput> => {
const execute = vi.fn(async (): Promise<SubmitWorkflowOutput> => {
await Promise.resolve();
return { success: true, workflowId: 'wf_unused' };
});
const onGuardFired = jest.fn();
const onGuardFired = vi.fn();
const state: WorkflowLoopState = {
workItemId: 'wi_test',
threadId: 'thread_1',
@ -260,8 +260,8 @@ describe('wrapSubmitExecuteWithIdentity', () => {
reason: 'workflow_save_failed',
guidance: 'Stop editing.',
});
const execute = jest
.fn<Promise<SubmitWorkflowOutput>, [SubmitWorkflowInput]>()
const execute = vi
.fn<(...args: [SubmitWorkflowInput]) => Promise<SubmitWorkflowOutput>>()
.mockResolvedValueOnce({
success: false,
errors: ['Workflow save failed.'],
@ -299,7 +299,7 @@ describe('wrapSubmitExecuteWithIdentity', () => {
reason: 'workflow_save_failed',
guidance: 'Stop editing.',
});
const execute = jest.fn(async (): Promise<SubmitWorkflowOutput> => {
const execute = vi.fn(async (): Promise<SubmitWorkflowOutput> => {
await gate;
return {
success: false,
@ -329,7 +329,7 @@ describe('wrapSubmitExecuteWithIdentity', () => {
});
it('ignores terminal remediation from a previous run', async () => {
const execute = jest.fn(async (): Promise<SubmitWorkflowOutput> => {
const execute = vi.fn(async (): Promise<SubmitWorkflowOutput> => {
await Promise.resolve();
return { success: true, workflowId: 'wf_current' };
});
@ -379,12 +379,12 @@ describe('wrapSubmitExecuteWithIdentity', () => {
successfulSubmitSeen: true,
postSubmitRemediationSubmitsUsed: 2,
};
const getWorkflowLoopState = jest
.fn<Promise<WorkflowLoopState | undefined>, []>()
const getWorkflowLoopState = vi
.fn<(...args: []) => Promise<WorkflowLoopState | undefined>>()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(terminalState);
const execute = jest.fn(async (input: SubmitWorkflowInput): Promise<SubmitWorkflowOutput> => {
const execute = vi.fn(async (input: SubmitWorkflowInput): Promise<SubmitWorkflowOutput> => {
await gate;
return { success: true, workflowId: input.workflowId ?? 'wf_1' };
});

View File

@ -1,6 +1,7 @@
/* eslint-disable import-x/order */
import { validateWorkflow, type WorkflowJSON } from '@n8n/workflow-sdk';
import { mock } from 'jest-mock-extended';
import type { INodeTypes, WorkflowStructureIssue } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { executeTool } from '../../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../../types';
@ -16,15 +17,13 @@ import {
} from '../submit-workflow.tool';
import { isTriggerNodeType } from '../workflow-json-utils';
jest.mock('@n8n/workflow-sdk', () => ({
validateWorkflow: jest.fn(() => ({ errors: [], warnings: [] })),
vi.mock('@n8n/workflow-sdk', () => ({
validateWorkflow: vi.fn(() => ({ errors: [], warnings: [] })),
}));
const { createSubmitWorkflowTool } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../submit-workflow.tool') as typeof import('../submit-workflow.tool');
import { createSubmitWorkflowTool } from '../submit-workflow.tool';
const mockedValidateWorkflow = jest.mocked(validateWorkflow);
const mockedValidateWorkflow = vi.mocked(validateWorkflow);
function makeContext(
permissions: InstanceAiContext['permissions'] = {} as InstanceAiContext['permissions'],
@ -210,7 +209,7 @@ describe('createSubmitWorkflowTool — successful submit metadata', () => {
const root = '/home/test/workspace/builders/builder-1';
const calls: Array<{ command: string; cwd?: string }> = [];
const workflowService = {
createFromWorkflowJSON: jest.fn(async () => {
createFromWorkflowJSON: vi.fn(async () => {
await Promise.resolve();
return { id: 'main-workflow-id' };
}),
@ -258,7 +257,7 @@ describe('createSubmitWorkflowTool — successful submit metadata', () => {
it('returns and reports workflow pin-data verification and referenced workflow IDs', async () => {
const attempts: SubmitWorkflowAttempt[] = [];
const workflowService = {
createFromWorkflowJSON: jest.fn(async () => {
createFromWorkflowJSON: vi.fn(async () => {
await Promise.resolve();
return { id: 'main-workflow-id' };
}),
@ -599,7 +598,7 @@ describe('createSubmitWorkflowTool — structured save-failure payload', () => {
};
const workflowService = {
createFromWorkflowJSON: jest.fn(async () => {
createFromWorkflowJSON: vi.fn(async () => {
await Promise.resolve();
throw saveError;
}),
@ -647,7 +646,7 @@ describe('createSubmitWorkflowTool — structured save-failure payload', () => {
it('still emits nodeIndex (but no errorDetails) when the save error is plain', async () => {
const workflowService = {
createFromWorkflowJSON: jest.fn(async () => {
createFromWorkflowJSON: vi.fn(async () => {
await Promise.resolve();
throw new Error('database unavailable');
}),

View File

@ -1,4 +1,5 @@
import type { NodeJSON, WorkflowJSON } from '@n8n/workflow-sdk';
import type { Mock } from 'vitest';
import type { InstanceAiContext, NodeDescription } from '../../../types';
import { validateWorkflowConfig } from '../validate-workflow.service';
@ -11,51 +12,51 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiCo
return {
userId: 'test-user',
workflowService: {
list: jest.fn(),
get: jest.fn(),
getAsWorkflowJSON: jest.fn(),
createFromWorkflowJSON: jest.fn(),
updateFromWorkflowJSON: jest.fn(),
archive: jest.fn(),
unarchive: jest.fn(),
publish: jest.fn(),
unpublish: jest.fn(),
clearAiTemporary: jest.fn(),
archiveIfAiTemporary: jest.fn(),
list: vi.fn(),
get: vi.fn(),
getAsWorkflowJSON: vi.fn(),
createFromWorkflowJSON: vi.fn(),
updateFromWorkflowJSON: vi.fn(),
archive: vi.fn(),
unarchive: vi.fn(),
publish: vi.fn(),
unpublish: vi.fn(),
clearAiTemporary: vi.fn(),
archiveIfAiTemporary: vi.fn(),
},
executionService: {
list: jest.fn(),
run: jest.fn(),
getStatus: jest.fn(),
getResult: jest.fn(),
stop: jest.fn(),
getDebugInfo: jest.fn(),
getNodeOutput: jest.fn(),
list: vi.fn(),
run: vi.fn(),
getStatus: vi.fn(),
getResult: vi.fn(),
stop: vi.fn(),
getDebugInfo: vi.fn(),
getNodeOutput: vi.fn(),
},
credentialService: {
list: jest.fn().mockResolvedValue([]),
get: jest.fn(),
delete: jest.fn(),
test: jest.fn(),
list: vi.fn().mockResolvedValue([]),
get: vi.fn(),
delete: vi.fn(),
test: vi.fn(),
},
nodeService: {
listAvailable: jest.fn(),
getDescription: jest.fn(),
listSearchable: jest.fn(),
getParameterIssues: jest.fn().mockResolvedValue({}),
listAvailable: vi.fn(),
getDescription: vi.fn(),
listSearchable: vi.fn(),
getParameterIssues: vi.fn().mockResolvedValue({}),
},
dataTableService: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
getSchema: jest.fn(),
addColumn: jest.fn(),
deleteColumn: jest.fn(),
renameColumn: jest.fn(),
queryRows: jest.fn(),
insertRows: jest.fn(),
updateRows: jest.fn(),
deleteRows: jest.fn(),
list: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
getSchema: vi.fn(),
addColumn: vi.fn(),
deleteColumn: vi.fn(),
renameColumn: vi.fn(),
queryRows: vi.fn(),
insertRows: vi.fn(),
updateRows: vi.fn(),
deleteRows: vi.fn(),
},
...overrides,
} as unknown as InstanceAiContext;
@ -116,7 +117,7 @@ describe('validateWorkflowConfig', () => {
describe('credential issues', () => {
it('flags a node whose required credential is unset', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'telegramApi', required: true }],
}),
@ -136,12 +137,12 @@ describe('validateWorkflowConfig', () => {
it('does not flag a required credential when one is set and exists for the user', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'telegramApi', required: true }],
}),
);
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'My Telegram', type: 'telegramApi' },
]);
const node = makeNode({
@ -156,12 +157,12 @@ describe('validateWorkflowConfig', () => {
it('emits a "do not exist" issue when the selected credential is not in the user\'s store', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'telegramApi', required: true }],
}),
);
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
(context.credentialService.list as Mock).mockResolvedValue([]);
const node = makeNode({
credentials: { telegramApi: { id: 'cred-missing', name: 'Foreign Telegram' } },
});
@ -175,12 +176,12 @@ describe('validateWorkflowConfig', () => {
it('emits a "not identified" issue when multiple stored credentials match the selected name', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'telegramApi', required: true }],
}),
);
(context.credentialService.list as jest.Mock).mockResolvedValue([
(context.credentialService.list as Mock).mockResolvedValue([
{ id: 'cred-1', name: 'Same Name', type: 'telegramApi' },
{ id: 'cred-2', name: 'Same Name', type: 'telegramApi' },
]);
@ -197,7 +198,7 @@ describe('validateWorkflowConfig', () => {
it('skips AI-gateway-managed credentials', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'openAiApi', required: true }],
}),
@ -221,7 +222,7 @@ describe('validateWorkflowConfig', () => {
it('respects displayOptions when filtering credential types', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [
{
@ -258,7 +259,7 @@ describe('validateWorkflowConfig', () => {
it('flags an HTTP Request node with genericCredentialType but no credentials set', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(httpRequestDescription);
(context.nodeService.getDescription as Mock).mockResolvedValue(httpRequestDescription);
const node = makeNode({
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
@ -278,7 +279,7 @@ describe('validateWorkflowConfig', () => {
it('flags an HTTP Request node with predefinedCredentialType missing credentials', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(httpRequestDescription);
(context.nodeService.getDescription as Mock).mockResolvedValue(httpRequestDescription);
const node = makeNode({
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
@ -299,10 +300,10 @@ describe('validateWorkflowConfig', () => {
describe('parameter issues', () => {
it('surfaces parameter validation errors from the node service', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
(context.nodeService.getParameterIssues as jest.Mock).mockResolvedValue({
(context.nodeService.getParameterIssues as Mock).mockResolvedValue({
chatId: ['Parameter "chatId" is required'],
});
const node = makeNode({ parameters: { chatId: '' } });
@ -319,12 +320,12 @@ describe('validateWorkflowConfig', () => {
it('honors ignoreIssues to suppress whole categories', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'telegramApi', required: true }],
}),
);
(context.nodeService.getParameterIssues as jest.Mock).mockResolvedValue({
(context.nodeService.getParameterIssues as Mock).mockResolvedValue({
chatId: ['Parameter "chatId" is required'],
});
const node = makeNode({ parameters: { chatId: '' } });
@ -342,7 +343,7 @@ describe('validateWorkflowConfig', () => {
describe('node-level skips', () => {
it('skips disabled nodes', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'telegramApi', required: true }],
}),
@ -357,7 +358,7 @@ describe('validateWorkflowConfig', () => {
it('emits typeUnknown when the node description cannot be resolved', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockRejectedValue(new Error('not found'));
(context.nodeService.getDescription as Mock).mockRejectedValue(new Error('not found'));
const node = makeNode({ type: 'n8n-nodes-base.madeUpType' });
const result = await validateWorkflowConfig(context, { workflow: makeWorkflow([node]) });
@ -371,10 +372,8 @@ describe('validateWorkflowConfig', () => {
it('resolves the workflow via workflowService.getAsWorkflowJSON', async () => {
const context = createMockContext();
const node = makeNode();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
makeWorkflow([node]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflow([node]));
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'telegramApi', required: true }],
}),
@ -396,7 +395,7 @@ describe('validateWorkflowConfig', () => {
context: InstanceAiContext,
perNodeInputs: Record<string, unknown[]>,
): void {
(context.nodeService as unknown as Record<string, unknown>).getResolvedNodeInputs = jest
(context.nodeService as unknown as Record<string, unknown>).getResolvedNodeInputs = vi
.fn()
.mockImplementation(async (_workflow: WorkflowJSON, nodeName: string) => {
return await Promise.resolve(perNodeInputs[nodeName] ?? []);
@ -405,7 +404,7 @@ describe('validateWorkflowConfig', () => {
it('flags an AI Agent node missing its required ai_languageModel attachment', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
name: '@n8n/n8n-nodes-langchain.agent',
displayName: 'AI Agent',
@ -445,7 +444,7 @@ describe('validateWorkflowConfig', () => {
it('does not flag an AI Agent when the language model is attached', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
name: '@n8n/n8n-nodes-langchain.agent',
displayName: 'AI Agent',
@ -493,7 +492,7 @@ describe('validateWorkflowConfig', () => {
it('does not flag optional inputs (required !== true)', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
name: '@n8n/n8n-nodes-langchain.agent',
displayName: 'AI Agent',
@ -515,7 +514,7 @@ describe('validateWorkflowConfig', () => {
it('does not flag plain-string inputs (no required field)', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
// Standard nodes have plain `'main'` string inputs — never carry a required flag.
@ -531,7 +530,7 @@ describe('validateWorkflowConfig', () => {
it('honors ignoreIssues: ["input"] to suppress input issues', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
name: '@n8n/n8n-nodes-langchain.agent',
displayName: 'AI Agent',
@ -554,7 +553,7 @@ describe('validateWorkflowConfig', () => {
it('skips input checks entirely when getResolvedNodeInputs is not implemented', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
// Adapter optionality: no getResolvedNodeInputs method on the service.
@ -568,7 +567,7 @@ describe('validateWorkflowConfig', () => {
it('uses the input.type as fallback message label when displayName is missing', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
name: '@n8n/n8n-nodes-langchain.agent',
displayName: 'AI Agent',
@ -593,7 +592,7 @@ describe('validateWorkflowConfig', () => {
context: InstanceAiContext,
runData: Record<string, unknown[]> | null,
): void {
(context.workflowService as unknown as Record<string, unknown>).getLatestRunData = jest
(context.workflowService as unknown as Record<string, unknown>).getLatestRunData = vi
.fn()
.mockImplementation(async (_workflowId: string) => {
return await Promise.resolve(runData);
@ -602,10 +601,10 @@ describe('validateWorkflowConfig', () => {
it('flags a node whose most recent execution had an error', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
stubLatestRunData(context, {
@ -623,10 +622,10 @@ describe('validateWorkflowConfig', () => {
it('does not flag a node whose most recent execution completed without error', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
stubLatestRunData(context, {
@ -641,10 +640,10 @@ describe('validateWorkflowConfig', () => {
it('does not flag any execution issues when the workflow has no execution history', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
stubLatestRunData(context, null);
@ -656,7 +655,7 @@ describe('validateWorkflowConfig', () => {
it('skips execution checks silently in inline-workflow mode', async () => {
const context = createMockContext();
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
// Even though getLatestRunData would return errors, inline mode has no
@ -674,10 +673,10 @@ describe('validateWorkflowConfig', () => {
it('honors ignoreIssues: ["execution"] to suppress execution flags', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
stubLatestRunData(context, {
@ -695,10 +694,10 @@ describe('validateWorkflowConfig', () => {
it('skips execution checks when the adapter does not implement getLatestRunData', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
// No stubLatestRunData — the optional method is absent on this adapter.
@ -710,10 +709,10 @@ describe('validateWorkflowConfig', () => {
it('reports execution alongside parameter/credential issues on the same node', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode()]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({
credentials: [{ name: 'telegramApi', required: true }],
}),
@ -737,10 +736,10 @@ describe('validateWorkflowConfig', () => {
it('handles task errors without a message field gracefully', async () => {
const context = createMockContext();
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
stubLatestRunData(context, {
@ -765,7 +764,7 @@ describe('validateWorkflowConfig', () => {
context: InstanceAiContext,
runData: Record<string, unknown[]> | null,
): void {
(context.workflowService as unknown as Record<string, unknown>).getLatestRunData = jest
(context.workflowService as unknown as Record<string, unknown>).getLatestRunData = vi
.fn()
.mockImplementation(async (_workflowId: string) => {
return await Promise.resolve(runData);
@ -774,10 +773,10 @@ describe('validateWorkflowConfig', () => {
it('drops the error message text from the summary when allowSendingParameterValues is false', async () => {
const context = createMockContext({ allowSendingParameterValues: false });
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
stubLatestRunData(context, {
@ -799,10 +798,10 @@ describe('validateWorkflowConfig', () => {
it('includes the error message when allowSendingParameterValues is true', async () => {
const context = createMockContext({ allowSendingParameterValues: true });
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
stubLatestRunData(context, {
@ -818,10 +817,10 @@ describe('validateWorkflowConfig', () => {
it('defaults to allowing message text when the flag is unset (undefined)', async () => {
const context = createMockContext(); // no flag in overrides
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
);
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
(context.nodeService.getDescription as Mock).mockResolvedValue(
makeDescription({ credentials: [] }),
);
stubLatestRunData(context, {

View File

@ -1,3 +1,5 @@
import type { MockedFunction } from 'vitest';
import { executeTool } from '../../../__tests__/tool-test-utils';
import type { SandboxWorkspace } from '../../../workspace/sandbox-fs';
import { writeFileViaSandbox } from '../../../workspace/sandbox-fs';
@ -8,16 +10,16 @@ import { createWriteSandboxFileTool } from '../write-sandbox-file.tool';
// Mocks
// ---------------------------------------------------------------------------
jest.mock('../../../workspace/sandbox-fs', () => ({
writeFileViaSandbox: jest.fn(),
vi.mock('../../../workspace/sandbox-fs', () => ({
writeFileViaSandbox: vi.fn(),
}));
jest.mock('../../../workspace/sandbox-setup', () => ({
getWorkspaceRoot: jest.fn(),
vi.mock('../../../workspace/sandbox-setup', () => ({
getWorkspaceRoot: vi.fn(),
}));
const mockWriteFile = writeFileViaSandbox as jest.MockedFunction<typeof writeFileViaSandbox>;
const mockGetRoot = getWorkspaceRoot as jest.MockedFunction<typeof getWorkspaceRoot>;
const mockWriteFile = writeFileViaSandbox as MockedFunction<typeof writeFileViaSandbox>;
const mockGetRoot = getWorkspaceRoot as MockedFunction<typeof getWorkspaceRoot>;
// ---------------------------------------------------------------------------
// Helpers
@ -26,7 +28,7 @@ const mockGetRoot = getWorkspaceRoot as jest.MockedFunction<typeof getWorkspaceR
function createMockWorkspace(): SandboxWorkspace {
return {
sandbox: {
executeCommand: jest.fn(),
executeCommand: vi.fn(),
},
};
}
@ -39,7 +41,7 @@ describe('createWriteSandboxFileTool', () => {
let workspace: SandboxWorkspace;
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
workspace = createMockWorkspace();
mockGetRoot.mockResolvedValue('/home/user/workspace');
});

View File

@ -1,16 +1,33 @@
/* eslint-disable import-x/order */
import { createRuntimeSkillRegistry, type BuiltTool } from '@n8n/agents';
import type { Context, ContextManager } from '@opentelemetry/api';
import * as langsmithModule from 'langsmith';
import { jsonParse } from 'n8n-workflow';
import type * as AsyncHooks from 'node:async_hooks';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import { createToolRegistry } from '../../tool-registry';
import { createAskUserTool } from '../../tools/shared/ask-user.tool';
import {
buildAgentTraceInputs,
createDetachedSubAgentTraceContext,
createInstanceAiTraceContext,
createInternalOperationTraceContext,
createTraceReplayOnlyContext,
continueInstanceAiTraceContext,
mergeTraceRunInputs,
redactLangSmithTelemetrySpan,
releaseTraceClient,
submitLangsmithUserFeedback,
withCurrentTraceSpan,
} from '../langsmith-tracing';
import { TraceWriter, type TraceToolCall, type TraceToolSuspend } from '../trace-replay';
jest.mock('@n8n/agents', () => {
const actual = jest.requireActual<Record<string, unknown>>('@n8n/agents');
const { AsyncLocalStorage } = jest.requireActual<typeof AsyncHooks>('node:async_hooks');
const { ROOT_CONTEXT, context, trace } = jest.requireActual<{
vi.mock('@n8n/agents', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@n8n/agents');
const { AsyncLocalStorage } = await vi.importActual<typeof AsyncHooks>('node:async_hooks');
const { ROOT_CONTEXT, context, trace } = await vi.importActual<{
ROOT_CONTEXT: Context;
context: {
active(): Context;
@ -118,8 +135,8 @@ jest.mock('@n8n/agents', () => {
};
const provider = {
forceFlush: jest.fn(async () => await Promise.resolve()),
shutdown: jest.fn(async () => await Promise.resolve()),
forceFlush: vi.fn(async () => await Promise.resolve()),
shutdown: vi.fn(async () => await Promise.resolve()),
};
class MockLangSmithTelemetry {
@ -198,7 +215,7 @@ jest.mock('@n8n/agents', () => {
};
});
jest.mock('langsmith', () => {
vi.mock('langsmith', () => {
const createFeedbackCalls: Array<{
runId: string;
key: string;
@ -243,7 +260,7 @@ jest.mock('langsmith', () => {
};
});
jest.mock('langsmith/traceable', () => {
vi.mock('langsmith/traceable', () => {
return {
traceable: () => {
throw new Error('Instance AI tracing must use OTel spans, not langsmith/traceable');
@ -284,8 +301,8 @@ type AgentsMockModule = {
ended: boolean;
}>;
getProvider: () => {
forceFlush: jest.Mock<Promise<void>, []>;
shutdown: jest.Mock<Promise<void>, []>;
forceFlush: Mock<(...args: []) => Promise<void>>;
shutdown: Mock<(...args: []) => Promise<void>>;
};
};
};
@ -301,22 +318,6 @@ function isExecutableTool(
);
}
const {
buildAgentTraceInputs,
createDetachedSubAgentTraceContext,
createInstanceAiTraceContext,
createInternalOperationTraceContext,
createTraceReplayOnlyContext,
continueInstanceAiTraceContext,
mergeTraceRunInputs,
redactLangSmithTelemetrySpan,
releaseTraceClient,
submitLangsmithUserFeedback,
withCurrentTraceSpan,
} =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../langsmith-tracing') as typeof import('../langsmith-tracing');
async function startForegroundActor(
tracing: NonNullable<Awaited<ReturnType<typeof createInstanceAiTraceContext>>>,
) {
@ -335,15 +336,11 @@ async function startForegroundActor(
tracing.orchestratorRun = actorRun;
return actorRun;
}
const { createAskUserTool } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../../tools/shared/ask-user.tool') as typeof import('../../tools/shared/ask-user.tool');
const { __mock: langsmithMock } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('langsmith') as LangSmithMockModule;
const { __mock: agentsMock } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@n8n/agents') as AgentsMockModule;
import * as agentsModule from '@n8n/agents';
const { __mock: langsmithMock } = langsmithModule as unknown as LangSmithMockModule;
const { __mock: agentsMock } = agentsModule as unknown as AgentsMockModule;
describe('createInstanceAiTraceContext', () => {
const originalLangSmithApiKey = process.env.LANGSMITH_API_KEY;
@ -1575,12 +1572,12 @@ describe('createInstanceAiTraceContext', () => {
const regularTool = {
name: 'templates',
description: 'Search templates',
handler: jest.fn(),
handler: vi.fn(),
};
const workspaceTool = {
name: 'workspace_execute_command',
description: 'Run a workspace command',
handler: jest.fn(),
handler: vi.fn(),
};
const wrappedTools = tracing!.wrapTools(
@ -1928,7 +1925,7 @@ describe('createInstanceAiTraceContext', () => {
{
name: 'workspace_write_file',
description: 'Write a file in the workspace.',
handler: jest.fn(async () => await Promise.resolve({ written: true })),
handler: vi.fn(async () => await Promise.resolve({ written: true })),
} as never,
],
]),
@ -2115,7 +2112,7 @@ describe('submitLangsmithUserFeedback', () => {
});
it('routes through the proxy client when proxyConfig is provided', async () => {
const getAuthHeaders = jest.fn().mockResolvedValue({ Authorization: 'Bearer token' });
const getAuthHeaders = vi.fn().mockResolvedValue({ Authorization: 'Bearer token' });
await submitLangsmithUserFeedback({
langsmithRunId: 'ls-run-3',
langsmithTraceId: 'ls-trace-3',

View File

@ -126,7 +126,7 @@ describe('parseSuspension', () => {
describe('asResumable', () => {
it('casts agent to Resumable interface', () => {
const agent = { resume: jest.fn() };
const agent = { resume: vi.fn() };
const resumable = asResumable(agent);
expect(resumable.resume).toBe(agent.resume);
});
@ -135,7 +135,7 @@ describe('asResumable', () => {
describe('resumeAgentStream', () => {
it('uses native agent resume in stream mode', async () => {
const resumed = { runId: 'run-2' };
const agent = { resume: jest.fn().mockResolvedValue(resumed) };
const agent = { resume: vi.fn().mockResolvedValue(resumed) };
await expect(resumeAgentStream(agent, { approved: true }, { runId: 'run-1' })).resolves.toBe(
resumed,

View File

@ -1,35 +1,35 @@
jest.mock('@n8n/workflow-sdk', () => ({
parseWorkflowCodeToBuilder: jest.fn(),
validateWorkflow: jest.fn(),
vi.mock('@n8n/workflow-sdk', () => ({
parseWorkflowCodeToBuilder: vi.fn(),
validateWorkflow: vi.fn(),
}));
jest.mock('../extract-code', () => ({
stripImportStatements: jest.fn((code: string) => code),
vi.mock('../extract-code', () => ({
stripImportStatements: vi.fn((code: string) => code),
}));
import { parseWorkflowCodeToBuilder, validateWorkflow } from '@n8n/workflow-sdk';
import { mock } from 'jest-mock-extended';
import type { INodeTypes } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { stripImportStatements } from '../extract-code';
import { parseAndValidate, partitionWarnings } from '../parse-validate';
const mockedParseWorkflowCodeToBuilder = jest.mocked(parseWorkflowCodeToBuilder);
const mockedValidateWorkflow = jest.mocked(validateWorkflow);
const mockedStripImportStatements = jest.mocked(stripImportStatements);
const mockedParseWorkflowCodeToBuilder = vi.mocked(parseWorkflowCodeToBuilder);
const mockedValidateWorkflow = vi.mocked(validateWorkflow);
const mockedStripImportStatements = vi.mocked(stripImportStatements);
function makeBuilder(overrides: Record<string, unknown> = {}) {
return {
regenerateNodeIds: jest.fn(),
validate: jest.fn().mockReturnValue({ errors: [], warnings: [] }),
toJSON: jest.fn().mockReturnValue({ name: 'Test', nodes: [], connections: {} }),
regenerateNodeIds: vi.fn(),
validate: vi.fn().mockReturnValue({ errors: [], warnings: [] }),
toJSON: vi.fn().mockReturnValue({ name: 'Test', nodes: [], connections: {} }),
...overrides,
};
}
describe('parseAndValidate', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
mockedStripImportStatements.mockImplementation((code) => code);
mockedValidateWorkflow.mockReturnValue({ errors: [], warnings: [] } as never);
});
@ -52,7 +52,7 @@ describe('parseAndValidate', () => {
it('collects graph validation errors and warnings', () => {
const builder = makeBuilder({
validate: jest.fn().mockReturnValue({
validate: vi.fn().mockReturnValue({
errors: [{ code: 'GRAPH_ERROR', message: 'Cycle detected' }],
warnings: [{ code: 'MISSING_TRIGGER', message: 'No trigger found' }],
}),
@ -85,7 +85,7 @@ describe('parseAndValidate', () => {
it('combines graph and schema validation issues', () => {
const builder = makeBuilder({
validate: jest.fn().mockReturnValue({
validate: vi.fn().mockReturnValue({
errors: [{ code: 'E1', message: 'graph error' }],
warnings: [],
}),

View File

@ -6,14 +6,14 @@ function createStorage() {
const records = new Map<string, Record<string, unknown>>();
const storage = {
getWorkItem: jest.fn(async (_threadId: string, workItemId: string) => {
getWorkItem: vi.fn(async (_threadId: string, workItemId: string) => {
return await Promise.resolve(
(records.get(workItemId) ?? null) as Awaited<
ReturnType<WorkflowLoopStorage['getWorkItem']>
>,
);
}),
saveWorkItem: jest.fn(
saveWorkItem: vi.fn(
async (
_threadId: string,
state: Record<string, unknown>,

View File

@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import * as fsp from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import type { Mock } from 'vitest';
import {
BuilderTemplatesService,
@ -55,8 +56,8 @@ function isLatestArchiveUrl(url: string): boolean {
return url.endsWith('/latest/templates.tar.gz');
}
function installMockFetch(state: MockState): jest.Mock {
const mock = jest.fn((input: string | URL | Request, init?: RequestInit) => {
function installMockFetch(state: MockState): Mock {
const mock = vi.fn((input: string | URL | Request, init?: RequestInit) => {
state.calls.fetch++;
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const headers = new Headers((init?.headers ?? {}) as Record<string, string>);
@ -194,7 +195,7 @@ describe('BuilderTemplatesService', () => {
const state = makeState();
state.archiveStatus = 500;
installMockFetch(state);
const dateNow = jest.spyOn(Date, 'now');
const dateNow = vi.spyOn(Date, 'now');
dateNow.mockReturnValue(1_000);
try {
@ -272,7 +273,7 @@ describe('BuilderTemplatesService', () => {
await fsp.writeFile(path.join(cacheDir, 'channel.txt'), 'exact');
// Block any network call so we know hydration came from disk.
globalThis.fetch = jest.fn(
globalThis.fetch = vi.fn(
() => new Response('', { status: 500 }),
) as unknown as typeof globalThis.fetch;
@ -423,7 +424,7 @@ describe('BuilderTemplatesService', () => {
const cacheDir = await makeTempDir();
const state = makeState();
state.sha256Override = null; // sidecar 404
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() };
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
installMockFetch(state);
const svc = new BuilderTemplatesService(makeOptions(cacheDir, { logger }));
@ -547,7 +548,7 @@ describe('BuilderTemplatesService', () => {
await fsp.writeFile(path.join(cacheDir, 'templates.tar.gz.sha256'), sha256Hex(archive));
await fsp.writeFile(path.join(cacheDir, 'channel.txt'), 'latest');
globalThis.fetch = jest.fn(
globalThis.fetch = vi.fn(
() => new Response('', { status: 500 }),
) as unknown as typeof globalThis.fetch;
@ -564,7 +565,7 @@ describe('BuilderTemplatesService', () => {
const cacheDir = await makeTempDir();
const state = makeState();
state.exactStatus = 404;
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() };
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
installMockFetch(state);
const svc = new BuilderTemplatesService(
@ -618,7 +619,7 @@ describe('builderTemplatesOptionsFromEnv', () => {
it('omits refreshIntervalMs and warns when refresh hours is not a number', () => {
clearEnv();
process.env.N8N_INSTANCE_AI_TEMPLATES_REFRESH_HOURS = 'banana';
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() };
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
const opts = builderTemplatesOptionsFromEnv({ logger });
expect(opts.refreshIntervalMs).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(

View File

@ -89,7 +89,7 @@ describe('createSandbox', () => {
});
it('should pass getAuthToken through to DaytonaSandbox in proxy mode (lazy resolution)', async () => {
const getAuthToken = jest.fn().mockResolvedValue('jwt-token-123');
const getAuthToken = vi.fn().mockResolvedValue('jwt-token-123');
const config: SandboxConfig = {
enabled: true,
provider: 'daytona',

View File

@ -1,12 +1,14 @@
const daytonaInstances: Array<{ config: unknown }> = [];
const { daytonaInstances } = vi.hoisted(() => ({
daytonaInstances: [] as Array<{ config: unknown }>,
}));
jest.mock('@daytonaio/sdk', () => {
vi.mock('../lazy-daytona', () => {
class Daytona {
constructor(public config: unknown) {
daytonaInstances.push({ config });
}
}
return { Daytona };
return { loadDaytona: () => ({ Daytona }) };
});
import { DaytonaAuthManager } from '../daytona-auth-manager';
@ -67,7 +69,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
it('fetches a token on first call and decodes its exp', async () => {
const token = makeJwt(now + HOUR_MS);
const getAuthToken = jest.fn().mockResolvedValue(token);
const getAuthToken = vi.fn().mockResolvedValue(token);
const manager = new DaytonaAuthManager({ getAuthToken, now: nowFn });
await manager.getClient();
@ -79,7 +81,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
});
it('reuses the cached client when well outside the skew window', async () => {
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(now + HOUR_MS));
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(now + HOUR_MS));
const manager = new DaytonaAuthManager({ getAuthToken, now: nowFn });
await manager.getClient();
@ -92,7 +94,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
});
it('refreshes when the next call is inside the skew window', async () => {
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
const getAuthToken = vi.fn<(...args: []) => Promise<string>>().mockImplementation(async () => {
await Promise.resolve();
return makeJwt(nowFn() + HOUR_MS);
});
@ -110,7 +112,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
it('serializes concurrent refreshes (single-flight)', async () => {
let resolveToken: (token: string) => void = () => {};
const getAuthToken = jest.fn(
const getAuthToken = vi.fn(
async () =>
await new Promise<string>((resolve) => {
resolveToken = resolve;
@ -130,7 +132,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
});
it('falls back to 30-minute TTL when the token is opaque', async () => {
const getAuthToken = jest.fn().mockResolvedValue('opaque-token');
const getAuthToken = vi.fn().mockResolvedValue('opaque-token');
const manager = new DaytonaAuthManager({ getAuthToken, now: nowFn });
await manager.getClient();
@ -147,7 +149,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
it('uses a configurable refresh skew', async () => {
const customSkewMs = 15 * MINUTE_MS;
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
const getAuthToken = vi.fn<(...args: []) => Promise<string>>().mockImplementation(async () => {
await Promise.resolve();
return makeJwt(nowFn() + HOUR_MS);
});
@ -171,7 +173,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
});
it('ignores non-positive refreshSkewMs and falls back to the default', async () => {
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(now + HOUR_MS));
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(now + HOUR_MS));
const manager = new DaytonaAuthManager({
getAuthToken,
refreshSkewMs: 0,
@ -186,7 +188,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
});
it('passes apiUrl through to the Daytona client', async () => {
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
const manager = new DaytonaAuthManager({
getAuthToken,
apiUrl: 'https://proxy.example.com',
@ -206,7 +208,7 @@ describe('DaytonaAuthManager (invariants)', () => {
});
it('rejects construction with both auth options', () => {
const getAuthToken = jest.fn().mockResolvedValue('jwt');
const getAuthToken = vi.fn().mockResolvedValue('jwt');
expect(
() =>
new DaytonaAuthManager({

View File

@ -1,3 +1,5 @@
import type { Mock } from 'vitest';
// Mock @daytonaio/sdk so we can drive sandbox creation, token refresh, and
// sandbox refetch behavior from Jest without touching the network.
@ -7,86 +9,101 @@ interface MockSandbox {
memory: number;
target: string;
state: string;
process: { executeCommand: jest.Mock };
fs: Record<string, jest.Mock>;
start: jest.Mock;
stop: jest.Mock;
delete: jest.Mock;
getWorkDir: jest.Mock;
}
function makeMockSandbox(id: string, state = 'started'): MockSandbox {
return {
id,
cpu: 2,
memory: 4,
target: 'us',
state,
process: {
executeCommand: jest.fn().mockResolvedValue({
exitCode: 0,
artifacts: { stdout: 'ok' },
result: 'ok',
}),
},
fs: {
downloadFile: jest.fn(),
uploadFile: jest.fn(),
deleteFile: jest.fn(),
createFolder: jest.fn(),
listFiles: jest.fn(),
getFileDetails: jest.fn(),
moveFiles: jest.fn(),
},
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
getWorkDir: jest.fn().mockResolvedValue('/home/daytona/workspace'),
};
process: { executeCommand: Mock };
fs: Record<string, Mock>;
start: Mock;
stop: Mock;
delete: Mock;
getWorkDir: Mock;
}
interface DaytonaClientLog {
id: number;
config: unknown;
get: jest.Mock<Promise<MockSandbox>, [string]>;
create: jest.Mock<Promise<MockSandbox>, [unknown, unknown?]>;
delete: jest.Mock;
get: Mock<(...args: [string]) => Promise<MockSandbox>>;
create: Mock<(...args: [unknown, unknown?]) => Promise<MockSandbox>>;
delete: Mock;
}
const clientLog: DaytonaClientLog[] = [];
let nextClientId = 1;
let nextSandboxId = 1;
const queuedGetErrors: Error[] = [];
const queuedCreateResults: Array<MockSandbox | Error> = [];
// All mock state, helpers, and SDK classes live inside vi.hoisted so they are
// initialized before the (hoisted) module imports run. The Daytona SDK is
// consumed in source via `loadDaytona()` (which `require()`s @daytonaio/sdk), so
// we mock the first-party `lazy-daytona` module rather than the package itself.
const {
clientLog,
queuedGetErrors,
queuedCreateResults,
makeMockSandbox,
Daytona,
DaytonaError,
DaytonaNotFoundError,
resetDaytonaMockState,
} = vi.hoisted(() => {
const clientLog: DaytonaClientLog[] = [];
let nextClientId = 1;
let nextSandboxId = 1;
const queuedGetErrors: Error[] = [];
const queuedCreateResults: Array<MockSandbox | Error> = [];
// Each client's get() returns a NEW sandbox object so the test can detect
// refetch (i.e. .process / .fs identity changes after rotation).
function makeDaytonaClientForLog(config: unknown): DaytonaClientLog {
const id = nextClientId++;
const get = jest.fn<Promise<MockSandbox>, [string]>().mockImplementation(async () => {
const queued = queuedGetErrors.shift();
if (queued !== undefined) {
return await Promise.reject(queued);
}
return await Promise.resolve(makeMockSandbox(`sb-${id}-${nextSandboxId++}`));
});
const create = jest
.fn<Promise<MockSandbox>, [unknown, unknown?]>()
.mockImplementation(async () => {
const queued = queuedCreateResults.shift();
if (queued instanceof Error) {
return await Promise.reject(queued);
}
if (queued) return await Promise.resolve(queued);
return await Promise.resolve(makeMockSandbox(`sb-create-${id}-${nextSandboxId++}`));
});
const del = jest.fn().mockResolvedValue(undefined);
const log: DaytonaClientLog = { id, config, get, create, delete: del };
clientLog.push(log);
return log;
}
function makeMockSandbox(id: string, state = 'started'): MockSandbox {
return {
id,
cpu: 2,
memory: 4,
target: 'us',
state,
process: {
executeCommand: vi.fn().mockResolvedValue({
exitCode: 0,
artifacts: { stdout: 'ok' },
result: 'ok',
}),
},
fs: {
downloadFile: vi.fn(),
uploadFile: vi.fn(),
deleteFile: vi.fn(),
createFolder: vi.fn(),
listFiles: vi.fn(),
getFileDetails: vi.fn(),
moveFiles: vi.fn(),
},
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
getWorkDir: vi.fn().mockResolvedValue('/home/daytona/workspace'),
};
}
// Each client's get() returns a NEW sandbox object so the test can detect
// refetch (i.e. .process / .fs identity changes after rotation).
function makeDaytonaClientForLog(config: unknown): DaytonaClientLog {
const id = nextClientId++;
const get = vi
.fn<(...args: [string]) => Promise<MockSandbox>>()
.mockImplementation(async () => {
const queued = queuedGetErrors.shift();
if (queued !== undefined) {
return await Promise.reject(queued);
}
return await Promise.resolve(makeMockSandbox(`sb-${id}-${nextSandboxId++}`));
});
const create = vi
.fn<(...args: [unknown, unknown?]) => Promise<MockSandbox>>()
.mockImplementation(async () => {
const queued = queuedCreateResults.shift();
if (queued instanceof Error) {
return await Promise.reject(queued);
}
if (queued) return await Promise.resolve(queued);
return await Promise.resolve(makeMockSandbox(`sb-create-${id}-${nextSandboxId++}`));
});
const del = vi.fn().mockResolvedValue(undefined);
const log: DaytonaClientLog = { id, config, get, create, delete: del };
clientLog.push(log);
return log;
}
jest.mock('@daytonaio/sdk', () => {
class Daytona {
private readonly log: DaytonaClientLog;
constructor(config: unknown) {
@ -114,10 +131,30 @@ jest.mock('@daytonaio/sdk', () => {
super(message, 404);
}
}
return { Daytona, DaytonaError, DaytonaNotFoundError };
function resetDaytonaMockState(): void {
clientLog.length = 0;
nextClientId = 1;
nextSandboxId = 1;
queuedGetErrors.length = 0;
queuedCreateResults.length = 0;
}
return {
clientLog,
queuedGetErrors,
queuedCreateResults,
makeMockSandbox,
Daytona,
DaytonaError,
DaytonaNotFoundError,
resetDaytonaMockState,
};
});
import type * as DaytonaSdk from '@daytonaio/sdk';
vi.mock('../lazy-daytona', () => ({
loadDaytona: () => ({ Daytona, DaytonaError, DaytonaNotFoundError }),
}));
import type { ErrorReporter, Logger } from '../../logger';
import { DaytonaSandbox } from '../daytona-sandbox';
@ -132,16 +169,15 @@ function makeJwt(expMs: number): string {
}
function queueNotFound(message = 'sandbox not found'): void {
const sdkMock = jest.requireMock<typeof DaytonaSdk>('@daytonaio/sdk');
queuedGetErrors.push(new sdkMock.DaytonaNotFoundError(message));
queuedGetErrors.push(new DaytonaNotFoundError(message));
}
function makeLogger(): Logger {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
@ -150,17 +186,13 @@ const MINUTE_MS = 60 * 1000;
const SKEW_MS = 5 * MINUTE_MS;
beforeEach(() => {
clientLog.length = 0;
nextClientId = 1;
nextSandboxId = 1;
queuedGetErrors.length = 0;
queuedCreateResults.length = 0;
resetDaytonaMockState();
});
describe('DaytonaSandbox (creation strategies)', () => {
it('falls back from snapshot creation to image creation and preserves sandbox labels', async () => {
const logger = makeLogger();
const errorReporter: ErrorReporter = { error: jest.fn() };
const errorReporter: ErrorReporter = { error: vi.fn() };
const snapshotError = new Error('snapshot missing');
queueNotFound('not found');
queuedCreateResults.push(snapshotError, makeMockSandbox('remote-sandbox'));
@ -232,7 +264,7 @@ describe('DaytonaSandbox (creation strategies)', () => {
});
it('reports image strategy failures and rethrows', async () => {
const errorReporter: ErrorReporter = { error: jest.fn() };
const errorReporter: ErrorReporter = { error: vi.fn() };
const imageError = new Error('image create failed');
queueNotFound('not found');
queuedCreateResults.push(imageError);
@ -279,7 +311,7 @@ describe('DaytonaSandbox (direct mode)', () => {
describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
it('mints a Daytona client only when the sandbox is first touched', () => {
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
new DaytonaSandbox({ name: 'thread-1', getAuthToken });
expect(getAuthToken).not.toHaveBeenCalled();
@ -287,7 +319,7 @@ describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
});
it('reuses the same Daytona client across calls within the TTL window', async () => {
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
const sandbox = new DaytonaSandbox({ name: 'thread-1', getAuthToken });
await sandbox.start();
@ -299,12 +331,14 @@ describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
});
it('refetches the Sandbox via client.get() after the JWT rotates', async () => {
jest.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
vi.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
try {
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
await Promise.resolve();
return makeJwt(Date.now() + HOUR_MS);
});
const getAuthToken = vi
.fn<(...args: []) => Promise<string>>()
.mockImplementation(async () => {
await Promise.resolve();
return makeJwt(Date.now() + HOUR_MS);
});
const sandbox = new DaytonaSandbox({ name: 'thread-1', getAuthToken });
await sandbox.start();
@ -313,7 +347,7 @@ describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
expect(clientLog).toHaveLength(1);
// Advance into the skew window.
jest.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
vi.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
await sandbox.executeCommand('echo', ['after-refresh']);
@ -326,29 +360,31 @@ describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
expect(sandbox.instance.process).not.toBe(firstProcess);
expect(sandbox.instance.process.executeCommand).toHaveBeenCalled();
} finally {
jest.useRealTimers();
vi.useRealTimers();
}
});
it('refreshes on ensureAuthFresh() before fs operations', async () => {
jest.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
vi.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
try {
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
await Promise.resolve();
return makeJwt(Date.now() + HOUR_MS);
});
const getAuthToken = vi
.fn<(...args: []) => Promise<string>>()
.mockImplementation(async () => {
await Promise.resolve();
return makeJwt(Date.now() + HOUR_MS);
});
const sandbox = new DaytonaSandbox({ name: 'thread-1', getAuthToken });
await sandbox.start();
expect(getAuthToken).toHaveBeenCalledTimes(1);
jest.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
vi.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
await sandbox.ensureAuthFresh();
expect(getAuthToken).toHaveBeenCalledTimes(2);
expect(clientLog).toHaveLength(2);
} finally {
jest.useRealTimers();
vi.useRealTimers();
}
});
});
@ -359,22 +395,22 @@ describe('DaytonaSandbox (remote sandbox gone during refetch)', () => {
// call into the sandbox triggers a token rotation; the refetch then surfaces
// the remote-gone condition.
async function startAndStageRemoteGone() {
jest.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
vi.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
const getAuthToken = vi.fn<(...args: []) => Promise<string>>().mockImplementation(async () => {
await Promise.resolve();
return makeJwt(Date.now() + HOUR_MS);
});
const sandbox = new DaytonaSandbox({ name: 'thread-1', getAuthToken });
await sandbox.start();
jest.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
vi.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
queueNotFound();
return sandbox;
}
afterEach(() => {
jest.useRealTimers();
vi.useRealTimers();
});
it('stop() treats remote NotFound as idempotent and clears the cache', async () => {

View File

@ -8,9 +8,8 @@ import {
import { createLazyRuntimeWorkspace } from '../lazy-runtime-workspace';
function createMockWorkspace() {
const executeCommand = jest.fn<
Promise<CommandResult>,
Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>
const executeCommand = vi.fn<
(...args: Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>) => Promise<CommandResult>
>(
async (_command, _args, options) =>
await Promise.resolve({
@ -26,22 +25,22 @@ function createMockWorkspace() {
name: 'Filesystem',
provider: 'test',
status: 'ready',
destroy: jest.fn(async () => {
destroy: vi.fn(async () => {
filesystem.status = 'destroyed';
await Promise.resolve();
}),
getInstructions: jest.fn(() => 'Real filesystem instructions.'),
readFile: jest.fn(async () => await Promise.resolve('hello')),
writeFile: jest.fn(async () => await Promise.resolve()),
appendFile: jest.fn(async () => await Promise.resolve()),
deleteFile: jest.fn(async () => await Promise.resolve()),
copyFile: jest.fn(async () => await Promise.resolve()),
moveFile: jest.fn(async () => await Promise.resolve()),
mkdir: jest.fn(async () => await Promise.resolve()),
rmdir: jest.fn(async () => await Promise.resolve()),
readdir: jest.fn(async () => await Promise.resolve([])),
exists: jest.fn(async () => await Promise.resolve(true)),
stat: jest.fn(
getInstructions: vi.fn(() => 'Real filesystem instructions.'),
readFile: vi.fn(async () => await Promise.resolve('hello')),
writeFile: vi.fn(async () => await Promise.resolve()),
appendFile: vi.fn(async () => await Promise.resolve()),
deleteFile: vi.fn(async () => await Promise.resolve()),
copyFile: vi.fn(async () => await Promise.resolve()),
moveFile: vi.fn(async () => await Promise.resolve()),
mkdir: vi.fn(async () => await Promise.resolve()),
rmdir: vi.fn(async () => await Promise.resolve()),
readdir: vi.fn(async () => await Promise.resolve([])),
exists: vi.fn(async () => await Promise.resolve(true)),
stat: vi.fn(
async (path: string) =>
await Promise.resolve({
name: path,
@ -58,16 +57,16 @@ function createMockWorkspace() {
name: 'Sandbox',
provider: 'test',
status: 'running',
stop: jest.fn(async () => {
stop: vi.fn(async () => {
sandbox.status = 'stopped';
await Promise.resolve();
}),
destroy: jest.fn(async () => {
destroy: vi.fn(async () => {
sandbox.status = 'destroyed';
await Promise.resolve();
}),
getInstructions: jest.fn(() => 'Real sandbox instructions.'),
getDefaultCommandEnv: jest.fn(() => ({ CUSTOM_ENV: 'enabled' })),
getInstructions: vi.fn(() => 'Real sandbox instructions.'),
getDefaultCommandEnv: vi.fn(() => ({ CUSTOM_ENV: 'enabled' })),
executeCommand,
};
@ -82,7 +81,7 @@ function createMockWorkspace() {
describe('createLazyRuntimeWorkspace', () => {
it('advertises workspace tools without creating the real workspace', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
const tools = lazyWorkspace.getTools();
@ -100,7 +99,7 @@ describe('createLazyRuntimeWorkspace', () => {
it('merges sandbox default env after the real workspace is created', async () => {
const { workspace, executeCommand } = createMockWorkspace();
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
const executeCommandTool = lazyWorkspace
.getTools()
@ -118,7 +117,7 @@ describe('createLazyRuntimeWorkspace', () => {
it('retries workspace creation after the first lazy initialization fails', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = jest
const ensureWorkspace = vi
.fn()
.mockRejectedValueOnce(new Error('setup failed'))
.mockResolvedValueOnce(workspace);
@ -137,7 +136,7 @@ describe('createLazyRuntimeWorkspace', () => {
it('retries workspace creation after the first lazy initialization returns unavailable', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = jest
const ensureWorkspace = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(workspace);
@ -156,7 +155,7 @@ describe('createLazyRuntimeWorkspace', () => {
it('reflects resolved provider statuses and instructions', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
expect(lazyWorkspace.filesystem?.status).toBe('pending');
@ -173,7 +172,7 @@ describe('createLazyRuntimeWorkspace', () => {
it('destroys the resolved workspace when the lazy workspace is destroyed', async () => {
const { workspace, filesystem, sandbox } = createMockWorkspace();
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
await lazyWorkspace.filesystem?.readFile('/workspace/report.md');

View File

@ -6,12 +6,12 @@ import { N8nSandboxServiceSandbox } from '../n8n-sandbox-sandbox';
// Mocks
// ---------------------------------------------------------------------------
const mockCreateSandbox = jest.fn();
const mockGetSandbox = jest.fn();
const mockDeleteSandbox = jest.fn();
const mockExec = jest.fn();
const mockCreateSandbox = vi.fn();
const mockGetSandbox = vi.fn();
const mockDeleteSandbox = vi.fn();
const mockExec = vi.fn();
jest.mock('@n8n/sandbox-client', () => {
vi.mock('@n8n/sandbox-client', () => {
class MockSandboxServiceError extends Error {
readonly status: number;
@ -69,7 +69,7 @@ function makeExecResult(overrides: Record<string, unknown> = {}) {
// ---------------------------------------------------------------------------
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
mockCreateSandbox.mockResolvedValue(makeSandboxRecord());
mockGetSandbox.mockResolvedValue(makeSandboxRecord());
mockDeleteSandbox.mockResolvedValue(undefined);

View File

@ -15,14 +15,14 @@ function createSandboxWorkspace(files: Map<string, string>): {
const workspace: SandboxWorkspace = {
filesystem: {
provider: 'local',
writeFile: jest.fn(async (path: string, content: string | Buffer) => {
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
await Promise.resolve();
}),
mkdir: jest.fn(async () => await Promise.resolve()),
mkdir: vi.fn(async () => await Promise.resolve()),
},
sandbox: {
executeCommand: jest.fn(async (command: string) => {
executeCommand: vi.fn(async (command: string) => {
const readMatch = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
if (readMatch) {
const content = files.get(readMatch[1]);
@ -166,12 +166,12 @@ describe('materializeWorkspaceBundle', () => {
const workspace: SandboxWorkspace = {
filesystem: {
provider: 'local',
writeFile: jest.fn(async (path: string, content: string | Buffer) => {
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
writeOrder.push(path);
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
await Promise.resolve();
}),
mkdir: jest.fn(async () => await Promise.resolve()),
mkdir: vi.fn(async () => await Promise.resolve()),
},
};

View File

@ -1,3 +1,5 @@
import type { Mock } from 'vitest';
import {
escapeSingleQuotes,
writeFileViaSandbox,
@ -6,8 +8,8 @@ import {
} from '../sandbox-fs';
function createMockWorkspace(overrides?: {
executeCommand?: jest.Mock;
processes?: { spawn: jest.Mock };
executeCommand?: Mock;
processes?: { spawn: Mock };
}) {
return {
sandbox: {
@ -38,7 +40,7 @@ describe('escapeSingleQuotes', () => {
describe('runInSandbox', () => {
it('should use executeCommand when available', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: 'output',
stderr: '',
@ -52,12 +54,12 @@ describe('runInSandbox', () => {
});
it('should fall back to processes.spawn when executeCommand is not available', async () => {
const waitFn = jest.fn().mockResolvedValue({
const waitFn = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: 'spawned output',
stderr: '',
});
const spawn = jest.fn().mockResolvedValue({ wait: waitFn });
const spawn = vi.fn().mockResolvedValue({ wait: waitFn });
const workspace = createMockWorkspace({ processes: { spawn } });
const result = await runInSandbox(workspace, 'ls -la', '/tmp');
@ -82,7 +84,7 @@ describe('runInSandbox', () => {
});
it('should return non-zero exit code without throwing', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 1,
stdout: '',
stderr: 'command not found',
@ -97,7 +99,7 @@ describe('runInSandbox', () => {
describe('writeFileViaSandbox', () => {
it('should create parent directory and write base64-encoded content', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: '',
stderr: '',
@ -123,7 +125,7 @@ describe('writeFileViaSandbox', () => {
});
it('should throw when the write command fails', async () => {
const executeCommand = jest
const executeCommand = vi
.fn()
.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // mkdir
.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // temp file
@ -136,7 +138,7 @@ describe('writeFileViaSandbox', () => {
});
it('should skip mkdir when file has no parent directory', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: '',
stderr: '',
@ -155,7 +157,7 @@ describe('writeFileViaSandbox', () => {
});
it('should split large content into multiple append commands', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: '',
stderr: '',
@ -175,7 +177,7 @@ describe('writeFileViaSandbox', () => {
});
it('does not assign to the read-only zsh builtin `status` when capturing exit code', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: '',
stderr: '',
@ -197,7 +199,7 @@ describe('writeFileViaSandbox', () => {
describe('readFileViaSandbox', () => {
it('should return file content on success', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: 'file content here',
stderr: '',
@ -215,7 +217,7 @@ describe('readFileViaSandbox', () => {
});
it('should return null when file does not exist', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 1,
stdout: '',
stderr: '',
@ -228,7 +230,7 @@ describe('readFileViaSandbox', () => {
});
it('should escape single quotes in file paths', async () => {
const executeCommand = jest.fn().mockResolvedValue({
const executeCommand = vi.fn().mockResolvedValue({
exitCode: 0,
stdout: 'content',
stderr: '',

View File

@ -1,5 +1,6 @@
import { jsonParse } from 'n8n-workflow';
import { gzipSync } from 'node:zlib';
import type { Mock } from 'vitest';
import type { InstanceAiContext, SearchableNodeDescription } from '../../types';
import type { BuilderTemplatesBundle } from '../builder-templates-service';
@ -11,30 +12,31 @@ type SetupSandboxWorkspace = typeof setupSandboxWorkspaceFunction;
type LinkWorkspaceSdkIfEnabled = (
workspace: SandboxWorkspace,
root: string,
logger?: { error: jest.Mock; info: jest.Mock },
logger?: { error: Mock; info: Mock },
) => Promise<void>;
type RunInSandboxMock = jest.Mock<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
type RunInSandboxMock = Mock<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>;
type ReadFileViaSandboxMock = jest.Mock<Promise<string | null>, [SandboxWorkspace, string]>;
type ReadFileViaSandboxMock = Mock<(...args: [SandboxWorkspace, string]) => Promise<string | null>>;
function createSetupContext(
templatesBundle: BuilderTemplatesBundle | null = null,
): InstanceAiContext {
return {
nodeService: {
listSearchable: jest.fn().mockResolvedValue([]),
listSearchable: vi.fn().mockResolvedValue([]),
},
workflowService: {
list: jest.fn().mockResolvedValue([]),
get: jest.fn(),
list: vi.fn().mockResolvedValue([]),
get: vi.fn(),
},
...(templatesBundle
? {
templatesService: {
getBundle: jest.fn().mockResolvedValue(templatesBundle),
getVersion: jest.fn().mockReturnValue(templatesBundle.version),
getBundle: vi.fn().mockResolvedValue(templatesBundle),
getVersion: vi.fn().mockReturnValue(templatesBundle.version),
},
}
: {}),
@ -42,25 +44,27 @@ function createSetupContext(
}
function createLocalWorkspace(
writeFile: jest.Mock<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>,
mkdir?: jest.Mock<Promise<void>, [string, { recursive?: boolean }?]>,
writeFile: Mock<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>>,
mkdir?: Mock<(...args: [string, { recursive?: boolean }?]) => Promise<void>>,
): SandboxWorkspace {
return {
filesystem: {
provider: 'local',
basePath: '/sandbox',
writeFile,
mkdir: mkdir ?? jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {}),
mkdir:
mkdir ??
vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise<void>>(async () => {}),
},
};
}
function loadSetupSandboxWorkspaceWithFsMocks(
async function loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox: RunInSandboxMock,
readFileViaSandbox: ReadFileViaSandboxMock,
): SetupSandboxWorkspace {
jest.resetModules();
jest.doMock('../sandbox-fs', () => ({
): Promise<SetupSandboxWorkspace> {
vi.resetModules();
vi.doMock('../sandbox-fs', () => ({
runInSandbox,
readFileViaSandbox,
writeFileViaSandbox: async (workspace: SandboxWorkspace, path: string) => {
@ -72,62 +76,54 @@ function loadSetupSandboxWorkspaceWithFsMocks(
escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"),
}));
let sandboxSetup: { setupSandboxWorkspace: SetupSandboxWorkspace } | undefined;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
sandboxSetup = require('../sandbox-setup') as {
setupSandboxWorkspace: SetupSandboxWorkspace;
};
});
const sandboxSetup = (await import('../sandbox-setup')) as {
setupSandboxWorkspace: SetupSandboxWorkspace;
};
if (!sandboxSetup) throw new Error('Failed to load sandbox setup module');
return sandboxSetup.setupSandboxWorkspace;
}
function loadLinkWorkspaceSdkWithMocks(
packWorkspaceSdk: jest.Mock,
async function loadLinkWorkspaceSdkWithMocks(
packWorkspaceSdk: Mock,
runInSandbox: RunInSandboxMock,
): LinkWorkspaceSdkIfEnabled {
jest.resetModules();
jest.doMock('../pack-workspace-sdk', () => ({
): Promise<LinkWorkspaceSdkIfEnabled> {
vi.resetModules();
vi.doMock('../pack-workspace-sdk', () => ({
isLinkWorkspaceSdkEnabled: () => true,
packWorkspaceSdk,
}));
jest.doMock('../sandbox-fs', () => ({
vi.doMock('../sandbox-fs', () => ({
runInSandbox,
readFileViaSandbox: jest.fn(),
writeFileViaSandbox: jest.fn(),
readFileViaSandbox: vi.fn(),
writeFileViaSandbox: vi.fn(),
escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"),
}));
let sandboxSetup: { linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled } | undefined;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
sandboxSetup = require('../sandbox-setup') as {
linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled;
};
});
const sandboxSetup = (await import('../sandbox-setup')) as {
linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled;
};
if (!sandboxSetup) throw new Error('Failed to load sandbox setup module');
return sandboxSetup.linkWorkspaceSdkIfEnabled;
}
function loadSandboxPackageJson(linkSdk: boolean): {
async function loadSandboxPackageJson(linkSdk: boolean): Promise<{
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
} {
jest.resetModules();
}> {
// Ensure no leftover sandbox-fs mock from a sibling describe affects this fresh
// import, then re-import sandbox-setup so its env-dependent PACKAGE_JSON constant
// is re-evaluated. Using `await import` (rather than `vi.importActual`) keeps the
// module-cache interaction consistent with the doMock-based loaders above.
vi.doUnmock('../sandbox-fs');
vi.resetModules();
if (linkSdk) {
process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK = '1';
} else {
delete process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
}
let packageJson = '';
jest.isolateModules(() => {
const sandboxSetup = jest.requireActual<{ PACKAGE_JSON: string }>('../sandbox-setup');
packageJson = sandboxSetup.PACKAGE_JSON;
});
const sandboxSetup = await import('../sandbox-setup');
const packageJson = sandboxSetup.PACKAGE_JSON;
return jsonParse<{
dependencies: Record<string, string>;
@ -139,7 +135,7 @@ describe('PACKAGE_JSON', () => {
const originalLinkSdk = process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
afterEach(() => {
jest.resetModules();
vi.resetModules();
if (originalLinkSdk === undefined) {
delete process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
} else {
@ -147,15 +143,15 @@ describe('PACKAGE_JSON', () => {
}
});
it('should include a registry SDK dependency when workspace SDK linking is disabled', () => {
const packageJson = loadSandboxPackageJson(false);
it('should include a registry SDK dependency when workspace SDK linking is disabled', async () => {
const packageJson = await loadSandboxPackageJson(false);
expect(packageJson.dependencies['@n8n/workflow-sdk']).toBeDefined();
expect(packageJson.dependencies.tsx).toBeDefined();
});
it('should omit the registry SDK dependency when workspace SDK linking is enabled', () => {
const packageJson = loadSandboxPackageJson(true);
it('should omit the registry SDK dependency when workspace SDK linking is enabled', async () => {
const packageJson = await loadSandboxPackageJson(true);
expect(packageJson.dependencies).not.toHaveProperty('@n8n/workflow-sdk');
expect(packageJson.dependencies.tsx).toBeDefined();
@ -164,28 +160,28 @@ describe('PACKAGE_JSON', () => {
describe('setupSandboxWorkspace', () => {
afterEach(() => {
jest.dontMock('../sandbox-fs');
jest.resetModules();
vi.doUnmock('../sandbox-fs');
vi.resetModules();
});
it('writes the initialized marker only after workspace files and npm install succeed', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
const readFileViaSandbox: ReadFileViaSandboxMock =
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
const writeFile = vi.fn<
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
>(async () => {});
await setupSandboxWorkspace(createLocalWorkspace(writeFile), createSetupContext());
@ -199,24 +195,26 @@ describe('setupSandboxWorkspace', () => {
});
it('always creates workflows/, src/, and chunks/ even when no workflows exist', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
const readFileViaSandbox: ReadFileViaSandboxMock =
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
const writeFile = vi.fn<
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
>(async () => {});
const mkdir = vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise<void>>(
async () => {},
);
const mkdir = jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {});
// Setup context defaults to an empty workflow list, mirroring a fresh DB.
await setupSandboxWorkspace(createLocalWorkspace(writeFile, mkdir), createSetupContext());
@ -231,23 +229,23 @@ describe('setupSandboxWorkspace', () => {
// Local provider is for SDK dev iteration; the agent operates fine without
// the curated reference set, so setupSandboxWorkspace must not pay the
// per-file/archive write cost here.
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
const readFileViaSandbox: ReadFileViaSandboxMock =
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
const writeFile = vi.fn<
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
>(async () => {});
const bundle: BuilderTemplatesBundle = {
archive: Buffer.from('opaque-archive-bytes'),
@ -264,27 +262,27 @@ describe('setupSandboxWorkspace', () => {
});
it('rejects setup file paths that escape the workspace root', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
const readFileViaSandbox: ReadFileViaSandboxMock =
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
const writeFile = vi.fn<
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
>(async () => {});
const context = createSetupContext();
const workflowService = context.workflowService as unknown as {
list: jest.Mock<Promise<Array<{ id: string }>>, [{ limit: number }]>;
get: jest.Mock<Promise<Record<string, unknown>>, [string]>;
list: Mock<(...args: [{ limit: number }]) => Promise<Array<{ id: string }>>>;
get: Mock<(...args: [string]) => Promise<Record<string, unknown>>>;
};
workflowService.list.mockResolvedValue([{ id: '../escape' }]);
workflowService.get.mockResolvedValue({ id: '../escape' });
@ -295,23 +293,23 @@ describe('setupSandboxWorkspace', () => {
});
it('does not write the initialized marker when npm install fails', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockResolvedValue({ exitCode: 1, stdout: '', stderr: 'install failed' });
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
const readFileViaSandbox: ReadFileViaSandboxMock =
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
const writeFile = vi.fn<
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
>(async () => {});
await expect(
setupSandboxWorkspace(createLocalWorkspace(writeFile), createSetupContext()),
@ -325,22 +323,22 @@ describe('setupSandboxWorkspace', () => {
});
it('uses command fallback when a filesystem marker write fails', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
const readFileViaSandbox: ReadFileViaSandboxMock =
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest
.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>()
const writeFile = vi
.fn<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>>()
.mockImplementation(async (path) => {
await Promise.resolve();
if (path === '/sandbox/.sandbox-initialized') {
@ -358,27 +356,27 @@ describe('setupSandboxWorkspace', () => {
});
it('includes the failing setup step when marker fallback fails', async () => {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockImplementation(async (_workspace, command) => {
await Promise.resolve();
return command.includes('.sandbox-initialized')
? { exitCode: 1, stdout: '', stderr: 'fallback failed' }
: { exitCode: 0, stdout: '', stderr: '' };
});
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
Promise<string | null>,
[SandboxWorkspace, string]
>();
const readFileViaSandbox: ReadFileViaSandboxMock =
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = jest
.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>()
const writeFile = vi
.fn<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>>()
.mockImplementation(async (path) => {
await Promise.resolve();
if (path === '/sandbox/.sandbox-initialized') {
@ -406,19 +404,24 @@ describe('setupSandboxWorkspace', () => {
const originalLinkSdk = process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK = '1';
const tarball = Buffer.from('sdk');
const packWorkspaceSdk = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
const packWorkspaceSdk = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
filename: 'workflow-sdk.tgz',
tarball,
version: '1.0.0',
sdkPath: '/host/sdk',
});
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const linkWorkspaceSdkIfEnabled = loadLinkWorkspaceSdkWithMocks(packWorkspaceSdk, runInSandbox);
const writeFile = jest.fn<Promise<void>, [string, Buffer, { recursive?: boolean }?]>(
const linkWorkspaceSdkIfEnabled = await loadLinkWorkspaceSdkWithMocks(
packWorkspaceSdk,
runInSandbox,
);
const writeFile = vi.fn<(...args: [string, Buffer, { recursive?: boolean }?]) => Promise<void>>(
async () => {},
);
const workspace = {
@ -451,8 +454,8 @@ describe('setupSandboxWorkspace', () => {
describe('getWorkspaceRoot', () => {
it('uses the resolved filesystem base path for lazy local workspaces', async () => {
let initialized = false;
const executeCommand = jest.fn();
const init = jest.fn<Promise<void>, []>(async () => {
const executeCommand = vi.fn();
const init = vi.fn<(...args: []) => Promise<void>>(async () => {
await Promise.resolve();
initialized = true;
});
@ -463,8 +466,8 @@ describe('getWorkspaceRoot', () => {
return initialized ? '/sandbox' : undefined;
},
init,
writeFile: jest.fn(),
mkdir: jest.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
},
sandbox: {
executeCommand,
@ -480,54 +483,55 @@ describe('getWorkspaceRoot', () => {
describe('writeCuratedExamples', () => {
afterEach(() => {
jest.dontMock('../sandbox-fs');
jest.resetModules();
vi.doUnmock('../sandbox-fs');
vi.resetModules();
});
type WriteCuratedExamples = (
workspace: SandboxWorkspace,
bundle: BuilderTemplatesBundle | null,
logger?: { debug?: jest.Mock; warn?: jest.Mock },
logger?: { debug?: Mock; warn?: Mock },
) => Promise<void>;
type FsMocks = {
runInSandbox: RunInSandboxMock;
writeFileViaSandbox: jest.Mock<Promise<void>, [SandboxWorkspace, string, string | Buffer]>;
writeFileViaSandbox: Mock<
(...args: [SandboxWorkspace, string, string | Buffer]) => Promise<void>
>;
};
function loadWriteCuratedExamples(): { fn: WriteCuratedExamples; fs: FsMocks } {
const runInSandbox: RunInSandboxMock = jest.fn<
Promise<{ exitCode: number; stdout: string; stderr: string }>,
[SandboxWorkspace, string, string?]
>();
async function loadWriteCuratedExamples(): Promise<{ fn: WriteCuratedExamples; fs: FsMocks }> {
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const writeFileViaSandbox = jest.fn<Promise<void>, [SandboxWorkspace, string, string | Buffer]>(
async () => {},
);
jest.resetModules();
jest.doMock('../sandbox-fs', () => ({
const writeFileViaSandbox = vi.fn<
(...args: [SandboxWorkspace, string, string | Buffer]) => Promise<void>
>(async () => {});
vi.resetModules();
vi.doMock('../sandbox-fs', () => ({
runInSandbox,
readFileViaSandbox: jest.fn().mockResolvedValue(null),
readFileViaSandbox: vi.fn().mockResolvedValue(null),
writeFileViaSandbox,
escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"),
}));
let loaded: { writeCuratedExamples: WriteCuratedExamples } | undefined;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
loaded = require('../sandbox-setup') as {
writeCuratedExamples: WriteCuratedExamples;
};
});
if (!loaded) throw new Error('Failed to load sandbox-setup');
const loaded = (await import('../sandbox-setup')) as {
writeCuratedExamples: WriteCuratedExamples;
};
return { fn: loaded.writeCuratedExamples, fs: { runInSandbox, writeFileViaSandbox } };
}
function makeDaytonaWorkspace() {
const filesystem = {
provider: 'daytona' as const,
writeFile: jest.fn<Promise<void>, [string, Buffer, { recursive?: boolean }?]>(async () => {}),
mkdir: jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {}),
writeFile: vi.fn<(...args: [string, Buffer, { recursive?: boolean }?]) => Promise<void>>(
async () => {},
),
mkdir: vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise<void>>(async () => {}),
};
const workspace = { filesystem } as unknown as SandboxWorkspace;
return { workspace, filesystem };
@ -601,7 +605,7 @@ describe('writeCuratedExamples', () => {
]);
it('writes the archive and runs tar on a non-local provider', async () => {
const { fn, fs } = loadWriteCuratedExamples();
const { fn, fs } = await loadWriteCuratedExamples();
const { workspace, filesystem } = makeDaytonaWorkspace();
await fn(workspace, { archive: ARCHIVE, version: '"v1"' });
@ -626,7 +630,7 @@ describe('writeCuratedExamples', () => {
});
it('falls back to shell writes when the workspace has no filesystem', async () => {
const { fn, fs } = loadWriteCuratedExamples();
const { fn, fs } = await loadWriteCuratedExamples();
const workspace = makeShellOnlyWorkspace();
await fn(workspace, { archive: ARCHIVE, version: '"v1"' });
@ -646,14 +650,14 @@ describe('writeCuratedExamples', () => {
});
it('warns and continues when tar exits non-zero', async () => {
const { fn, fs } = loadWriteCuratedExamples();
const { fn, fs } = await loadWriteCuratedExamples();
fs.runInSandbox.mockImplementation(async (_, cmd) => {
const stderr = cmd.includes('tar -xzf') ? 'tar: bad archive' : '';
const exitCode = cmd.includes('tar -xzf') ? 1 : 0;
return await Promise.resolve({ exitCode, stdout: '', stderr });
});
const { workspace } = makeDaytonaWorkspace();
const logger = { debug: jest.fn(), warn: jest.fn() };
const logger = { debug: vi.fn(), warn: vi.fn() };
// Must not throw.
await fn(workspace, { archive: ARCHIVE, version: '"v1"' }, logger);
@ -672,9 +676,9 @@ describe('writeCuratedExamples', () => {
['hardlink entry', makeTarGz([{ name: 'link.ts', typeFlag: '1', linkName: 'target.ts' }])],
['malformed gzip', Buffer.from('not-a-gzip-archive')],
])('rejects an archive with %s before writing it', async (_label, archive) => {
const { fn, fs } = loadWriteCuratedExamples();
const { fn, fs } = await loadWriteCuratedExamples();
const { workspace, filesystem } = makeDaytonaWorkspace();
const logger = { debug: jest.fn(), warn: jest.fn() };
const logger = { debug: vi.fn(), warn: vi.fn() };
await fn(workspace, { archive, version: '"v1"' }, logger);
@ -688,7 +692,7 @@ describe('writeCuratedExamples', () => {
});
it('no-ops when bundle.archive is null', async () => {
const { fn, fs } = loadWriteCuratedExamples();
const { fn, fs } = await loadWriteCuratedExamples();
const { workspace, filesystem } = makeDaytonaWorkspace();
await fn(workspace, { archive: null, version: null });
@ -698,7 +702,7 @@ describe('writeCuratedExamples', () => {
});
it('no-ops when bundle is null', async () => {
const { fn, fs } = loadWriteCuratedExamples();
const { fn, fs } = await loadWriteCuratedExamples();
const { workspace, filesystem } = makeDaytonaWorkspace();
await fn(workspace, null);
@ -708,16 +712,18 @@ describe('writeCuratedExamples', () => {
});
it('skips the local provider even with a non-empty bundle', async () => {
const { fn, fs } = loadWriteCuratedExamples();
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
async () => {},
);
const { fn, fs } = await loadWriteCuratedExamples();
const writeFile = vi.fn<
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
>(async () => {});
const workspace = {
filesystem: {
provider: 'local',
basePath: '/sandbox',
writeFile,
mkdir: jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {}),
mkdir: vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise<void>>(
async () => {},
),
},
} as unknown as SandboxWorkspace;

View File

@ -4,6 +4,7 @@ import {
type WorkspaceFilesystem,
type WorkspaceSandbox,
} from '@n8n/agents';
import type { Mock } from 'vitest';
import { createScopedWorkspace } from '../scoped-workspace';
@ -13,31 +14,31 @@ function createFilesystem(overrides: Partial<WorkspaceFilesystem> = {}): Workspa
name: 'filesystem',
provider: 'test',
status: 'ready',
readFile: jest.fn(async () => await Promise.resolve('content')),
writeFile: jest.fn(async () => {
readFile: vi.fn(async () => await Promise.resolve('content')),
writeFile: vi.fn(async () => {
await Promise.resolve();
}),
appendFile: jest.fn(async () => {
appendFile: vi.fn(async () => {
await Promise.resolve();
}),
deleteFile: jest.fn(async () => {
deleteFile: vi.fn(async () => {
await Promise.resolve();
}),
copyFile: jest.fn(async () => {
copyFile: vi.fn(async () => {
await Promise.resolve();
}),
moveFile: jest.fn(async () => {
moveFile: vi.fn(async () => {
await Promise.resolve();
}),
mkdir: jest.fn(async () => {
mkdir: vi.fn(async () => {
await Promise.resolve();
}),
rmdir: jest.fn(async () => {
rmdir: vi.fn(async () => {
await Promise.resolve();
}),
readdir: jest.fn(async () => await Promise.resolve([])),
exists: jest.fn(async () => await Promise.resolve(true)),
stat: jest.fn(
readdir: vi.fn(async () => await Promise.resolve([])),
exists: vi.fn(async () => await Promise.resolve(true)),
stat: vi.fn(
async () =>
await Promise.resolve({
name: 'workflow.ts',
@ -52,7 +53,7 @@ function createFilesystem(overrides: Partial<WorkspaceFilesystem> = {}): Workspa
};
}
function createSandbox(executeCommand: jest.Mock | null = jest.fn()): WorkspaceSandbox {
function createSandbox(executeCommand: Mock | null = vi.fn()): WorkspaceSandbox {
const result: CommandResult = {
success: true,
exitCode: 0,
@ -103,7 +104,7 @@ describe('createScopedWorkspace', () => {
});
it('runs commands from the builder root and merges scoped environment variables', async () => {
const executeCommand = jest.fn();
const executeCommand = vi.fn();
const sandbox = createSandbox(executeCommand);
const workspace = createScopedWorkspace(new Workspace({ sandbox }), root, {
N8N_WORKSPACE_DIR: root,
@ -121,7 +122,7 @@ describe('createScopedWorkspace', () => {
});
it('rejects command working directories outside the builder root', async () => {
const executeCommand = jest.fn();
const executeCommand = vi.fn();
const sandbox = createSandbox(executeCommand);
const workspace = createScopedWorkspace(new Workspace({ sandbox }), root);

View File

@ -1,6 +1,12 @@
// Mock the Daytona SDK before importing — its source has require() paths that
// jest can't resolve in this monorepo, and we don't need the real types here.
jest.mock('@daytonaio/sdk', () => {
/* eslint-disable import-x/order */
import type { Mock } from 'vitest';
// The Daytona SDK is consumed in source via `loadDaytona()` (which `require()`s
// @daytonaio/sdk — a path the test runner can't resolve in this monorepo), so we
// mock the first-party `lazy-daytona` module. The mock classes live in vi.hoisted
// so they are shared between the mock factory and the test (`instanceof` checks in
// source must see the same DaytonaError the test constructs).
const { DaytonaError, DaytonaNotFoundError, Image } = vi.hoisted(() => {
class DaytonaError extends Error {
statusCode?: number;
constructor(message: string, statusCode?: number) {
@ -38,7 +44,10 @@ jest.mock('@daytonaio/sdk', () => {
return { DaytonaError, DaytonaNotFoundError, Image };
});
import { DaytonaError } from '@daytonaio/sdk';
vi.mock('../lazy-daytona', () => ({
loadDaytona: () => ({ DaytonaError, DaytonaNotFoundError, Image }),
}));
import {
RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
type RuntimeSkillLinkedFiles,
@ -67,8 +76,8 @@ interface CreateSnapshotParams {
}
interface FakeSnapshotApi {
get: jest.Mock<Promise<{ name: string }>, [string]>;
create: jest.Mock<Promise<{ name: string }>, [CreateSnapshotParams, unknown?]>;
get: Mock<(...args: [string]) => Promise<{ name: string }>>;
create: Mock<(...args: [CreateSnapshotParams, unknown?]) => Promise<{ name: string }>>;
}
interface FakeDaytona {
@ -114,8 +123,8 @@ function createRuntimeSkillSource(skillsHash: string): RuntimeSkillSource {
function makeFakeDaytona(): FakeDaytona {
return {
snapshot: {
get: jest.fn<Promise<{ name: string }>, [string]>(),
create: jest.fn<Promise<{ name: string }>, [CreateSnapshotParams, unknown?]>(),
get: vi.fn<(...args: [string]) => Promise<{ name: string }>>(),
create: vi.fn<(...args: [CreateSnapshotParams, unknown?]) => Promise<{ name: string }>>(),
},
};
}
@ -288,7 +297,7 @@ describe('SnapshotManager.createSnapshot', () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
const onLogs = jest.fn();
const onLogs = vi.fn();
await manager.createSnapshot(daytona as never, { timeout: 1800, onLogs });
@ -386,7 +395,7 @@ describe('SnapshotManager.ensureSnapshot', () => {
});
it('reports transient failures via the error reporter', async () => {
const errorReporter = { error: jest.fn() };
const errorReporter = { error: vi.fn() };
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
const daytona = makeFakeDaytona();
const error = new DaytonaError('upstream 500', 500);
@ -403,7 +412,7 @@ describe('SnapshotManager.ensureSnapshot', () => {
});
it('does not report when create succeeds', async () => {
const errorReporter = { error: jest.fn() };
const errorReporter = { error: vi.fn() };
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
@ -414,7 +423,7 @@ describe('SnapshotManager.ensureSnapshot', () => {
});
it('does not report 409/already-exists as an error', async () => {
const errorReporter = { error: jest.fn() };
const errorReporter = { error: vi.fn() };
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockRejectedValue(new DaytonaError('already exists', 409));

View File

@ -8,18 +8,18 @@ function createWorkspaceTarget(files: Map<string, string>): {
const writes = new Map<string, string>();
const target: WorkspaceFileTarget = {
filesystem: {
readFile: jest.fn(async (path: string) => {
readFile: vi.fn(async (path: string) => {
const content = files.get(path);
if (content === undefined) throw new Error('missing');
return await Promise.resolve(content);
}),
writeFile: jest.fn(async (path: string, content: string | Buffer) => {
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
await Promise.resolve();
}),
},
sandbox: {
executeCommand: jest.fn(async (command: string) => {
executeCommand: vi.fn(async (command: string) => {
const readMatch = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
if (readMatch) {
const content = files.get(readMatch[1]);
@ -49,7 +49,7 @@ describe('workspace-files', () => {
it('reads via sandbox commands when no filesystem reader is available', async () => {
const { target } = createWorkspaceTarget(new Map([['/tmp/manifest.json', '{"ok":true}']]));
target.filesystem = {
writeFile: jest.fn(async () => {}),
writeFile: vi.fn(async () => {}),
};
await expect(readWorkspaceFile(target, '/tmp/manifest.json')).resolves.toBe('{"ok":true}');

View File

@ -12,7 +12,7 @@
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
"types": ["node", "jest"]
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,64 @@
import path from 'node:path';
import { mergeConfig, type Plugin } from 'vite';
import { createVitestConfig } from '@n8n/vitest-config/node';
/**
* `src/tools/index.ts` lazy-loads each tool module with `require('./x.tool')`.
* Under the production `tsc` build that resolves to the compiled `dist/*.js`, but
* Vitest's module runner hands the (ESM) source a Node `createRequire`, which
* cannot resolve relative `.ts` files and `vi.mock` only intercepts ESM imports,
* not these `require()`s. This transform (test-time only; the source on disk is
* untouched) rewrites those `require('spec')` calls into eager static
* `import * as __lazymod_N` bindings so Vite resolves them and `vi.mock` applies.
*/
function rewriteLazyRequireForTests(): Plugin {
return {
name: 'instance-ai-rewrite-lazy-require',
enforce: 'pre',
transform(code, id) {
if (!id.replace(/\\/g, '/').endsWith('/src/tools/index.ts')) return null;
const requireRe = /require\((['"])([^'"]+)\1\)/g;
const specs: string[] = [];
for (const match of code.matchAll(requireRe)) {
if (!specs.includes(match[2])) specs.push(match[2]);
}
if (specs.length === 0) return null;
const imports = specs
.map((spec, index) => `import * as __lazymod_${index} from '${spec}';`)
.join('\n');
const rewritten = code.replace(
requireRe,
(_full, _quote, spec: string) => `__lazymod_${specs.indexOf(spec)}`,
);
return { code: `${imports}\n${rewritten}`, map: null };
},
};
}
export default mergeConfig(
createVitestConfig({
// Parity with the previous root Jest config, which set `restoreMocks: true`.
// Most test files rely on mocks being restored automatically between tests.
restoreMocks: true,
}),
{
plugins: [rewriteLazyRequireForTests()],
resolve: {
alias: [
{ find: '@', replacement: path.resolve(__dirname, './src') },
// zod has dual ESM/CJS exports (two separate `ZodType` class identities).
// Workspace deps CJS-require zod while test files ESM-import it, so
// `instanceof` checks (e.g. in sanitize-mcp-schemas) fail across the two
// module instances. Pin the top-level `zod` import to the CJS file so all
// code paths share one instance. Subpaths like `zod/v4` resolve normally.
{
find: /^zod$/,
replacement: path.resolve(
__dirname,
'../../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/cjs/index.js',
),
},
],
},
},
);

View File

@ -1981,6 +1981,9 @@ importers:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@n8n/vitest-config':
specifier: workspace:*
version: link:../vitest-config
'@types/luxon':
specifier: 3.2.0
version: 3.2.0
@ -1990,9 +1993,18 @@ importers:
'@types/turndown':
specifier: ^5.0.5
version: 5.0.6
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.1(vitest@4.1.1)
tsx:
specifier: 'catalog:'
version: 4.19.3
vitest:
specifier: 'catalog:'
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
vitest-mock-extended:
specifier: 'catalog:'
version: 3.1.0(typescript@6.0.2)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
packages/@n8n/json-schema-to-zod:
devDependencies: