From 25766222b8954c2b722d71d756042fa1aba3ed2d Mon Sep 17 00:00:00 2001 From: Matsu Date: Wed, 3 Jun 2026 09:48:27 +0300 Subject: [PATCH] chore: Migrate instance-ai from Jest to Vitest (#31463) --- .../__tests__/comparison-compare.test.ts | 4 +- .../__tests__/data-workflows-schema.test.ts | 15 +- .../__tests__/data-workflows.test.ts | 15 +- .../__tests__/dataset-sync.test.ts | 46 ++- .../__tests__/normalize-workflow.test.ts | 1 - .../__tests__/prebuilt-workflows.test.ts | 10 +- .../__tests__/runner-prebuilt.test.ts | 23 +- .../__tests__/runner-workflow-checks.test.ts | 11 +- .../__tests__/subagent-runner.test.ts | 18 +- .../instance-ai/evaluations/tsconfig.json | 2 +- packages/@n8n/instance-ai/jest.config.js | 2 - packages/@n8n/instance-ai/package.json | 11 +- .../agent/__tests__/instance-agent.test.ts | 116 +++--- .../mcp-tool-name-validation.test.ts | 2 +- .../__tests__/sanitize-mcp-schemas.test.ts | 16 +- .../materialize-knowledge-base.test.ts | 6 +- .../mcp/__tests__/mcp-client-manager.test.ts | 67 +-- .../src/parsers/__tests__/docx-parser.test.ts | 6 +- .../src/parsers/__tests__/pdf-parser.test.ts | 20 +- .../__tests__/validate-attachments.test.ts | 2 +- .../src/parsers/structured-file-parser.ts | 2 +- .../__tests__/planned-task-service.test.ts | 18 +- .../__tests__/background-task-manager.test.ts | 38 +- .../resumable-stream-executor.test.ts | 38 +- .../__tests__/run-state-registry.test.ts | 46 +-- .../runtime/__tests__/stream-runner.test.ts | 44 +- .../materialize-runtime-skills.test.ts | 19 +- .../@n8n/instance-ai/src/source-map-filter.ts | 2 +- .../__tests__/planned-task-storage.test.ts | 18 +- .../terminal-outcome-storage.test.ts | 12 +- .../thread-iteration-log-storage.test.ts | 26 +- .../storage/__tests__/thread-patch.test.ts | 36 +- .../__tests__/thread-task-storage.test.ts | 25 +- .../__tests__/workflow-loop-storage.test.ts | 30 +- .../__tests__/consume-with-hitl.test.ts | 18 +- .../tools/__tests__/credentials.tool.test.ts | 101 +++-- .../tools/__tests__/data-tables.tool.test.ts | 131 +++--- .../tools/__tests__/executions.tool.test.ts | 71 ++-- .../src/tools/__tests__/index.test.ts | 112 ++--- .../src/tools/__tests__/nodes.tool.test.ts | 92 ++--- .../src/tools/__tests__/research.tool.test.ts | 199 ++++----- .../tools/__tests__/task-control.tool.test.ts | 22 +- .../tools/__tests__/templates.tool.test.ts | 2 +- .../tools/__tests__/workflows.tool.test.ts | 183 ++++----- .../tools/__tests__/workspace.tool.test.ts | 117 +++--- .../__tests__/parse-file.tool.test.ts | 74 ++-- .../create-empty-eval-data-table.test.ts | 68 ++-- .../tools/evals/__tests__/evals.tool.test.ts | 65 +-- .../extract-rows-from-history.service.test.ts | 139 ++++--- .../generate-sample-rows.service.test.ts | 39 +- ...generate-tool-ref-pin-data.service.test.ts | 17 +- .../create-tools-from-mcp-server.test.ts | 35 +- .../__tests__/agent-persistence.test.ts | 4 +- .../complete-checkpoint.tool.test.ts | 68 ++-- .../__tests__/delegate.tool.test.ts | 27 +- .../__tests__/eval-data-agent.tool.test.ts | 103 +++-- .../__tests__/eval-setup-agent.tools.test.ts | 14 +- .../orchestration/__tests__/plan.tool.test.ts | 83 ++-- .../__tests__/planner-briefing.test.ts | 12 +- .../__tests__/planner-run-coordinator.test.ts | 10 +- .../report-verification-verdict.tool.test.ts | 44 +- .../__tests__/tracing-utils.test.ts | 19 +- .../verify-built-workflow.tool.test.ts | 101 ++--- .../instance-ai/src/tools/research.tool.ts | 2 +- .../__tests__/build-workflow.tool.test.ts | 100 ++--- .../__tests__/resolve-credentials.test.ts | 13 +- .../__tests__/setup-workflow.service.test.ts | 331 ++++++++------- .../submit-workflow-identity.test.ts | 18 +- .../__tests__/submit-workflow.tool.test.ts | 21 +- .../validate-workflow.service.test.ts | 177 ++++---- .../__tests__/write-sandbox-file.tool.test.ts | 18 +- .../__tests__/langsmith-tracing.test.ts | 75 ++-- .../utils/__tests__/stream-helpers.test.ts | 4 +- .../__tests__/parse-validate.test.ts | 30 +- .../__tests__/workflow-task-service.test.ts | 4 +- .../builder-templates-service.test.ts | 17 +- .../__tests__/create-workspace.test.ts | 2 +- .../__tests__/daytona-auth-manager.test.ts | 26 +- .../__tests__/daytona-sandbox.test.ts | 248 +++++++----- .../__tests__/lazy-runtime-workspace.test.ts | 51 ++- .../__tests__/n8n-sandbox-sandbox.test.ts | 12 +- .../prebaked-workspace-bundle.test.ts | 10 +- .../workspace/__tests__/sandbox-fs.test.ts | 30 +- .../workspace/__tests__/sandbox-setup.test.ts | 382 +++++++++--------- .../__tests__/scoped-workspace.test.ts | 29 +- .../__tests__/snapshot-manager.test.ts | 33 +- .../__tests__/workspace-files.test.ts | 8 +- packages/@n8n/instance-ai/tsconfig.json | 2 +- packages/@n8n/instance-ai/vite.config.ts | 64 +++ pnpm-lock.yaml | 12 + 90 files changed, 2321 insertions(+), 2115 deletions(-) delete mode 100644 packages/@n8n/instance-ai/jest.config.js create mode 100644 packages/@n8n/instance-ai/vite.config.ts diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/comparison-compare.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/comparison-compare.test.ts index a94a26dbc7c..e6196596df6 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/comparison-compare.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/comparison-compare.test.ts @@ -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, diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/data-workflows-schema.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/data-workflows-schema.test.ts index 48d946fb8a3..cde986820ed 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/data-workflows-schema.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/data-workflows-schema.test.ts @@ -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); }); diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/data-workflows.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/data-workflows.test.ts index 7aa8b523c57..0ebbe606530 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/data-workflows.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/data-workflows.test.ts @@ -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); mockedReadFile.mockReturnValue(STUB_TEST_CASE); }); diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/dataset-sync.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/dataset-sync.test.ts index 2150501fa54..212c0230678 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/dataset-sync.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/dataset-sync.test.ts @@ -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 }>; function buildClient(existing: Example[] = []): { client: Client; - createExamples: jest.Mock, [UpsertArg]>; - updateExamples: jest.Mock, [UpsertArg]>; - deleteExamples: jest.Mock, [string[]]>; + createExamples: Mock<(...args: [UpsertArg]) => Promise>; + updateExamples: Mock<(...args: [UpsertArg]) => Promise>; + deleteExamples: Mock<(...args: [string[]]) => Promise>; } { - const createExamples = jest.fn, [UpsertArg]>().mockResolvedValue(undefined); - const updateExamples = jest.fn, [UpsertArg]>().mockResolvedValue(undefined); - const deleteExamples = jest.fn, [string[]]>().mockResolvedValue(undefined); + const createExamples = vi + .fn<(...args: [UpsertArg]) => Promise>() + .mockResolvedValue(undefined); + const updateExamples = vi + .fn<(...args: [UpsertArg]) => Promise>() + .mockResolvedValue(undefined); + const deleteExamples = vi + .fn<(...args: [string[]]) => Promise>() + .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 () => { diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/normalize-workflow.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/normalize-workflow.test.ts index 739127e3784..6c1a6b13989 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/normalize-workflow.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/normalize-workflow.test.ts @@ -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 diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/prebuilt-workflows.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/prebuilt-workflows.test.ts index 2ec7fe20e98..a1c7208f46c 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/prebuilt-workflows.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/prebuilt-workflows.test.ts @@ -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); diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/runner-prebuilt.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/runner-prebuilt.test.ts index 3bf3da40fd4..aabc4def230 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/runner-prebuilt.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/runner-prebuilt.test.ts @@ -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> = {}): { +function makeClient(overrides: Partial> = {}): { client: N8nClient; - mocks: Record; + mocks: Record; } { - const mocks: Record = { - getWorkflow: jest.fn(), - sendMessage: jest.fn(), - deleteWorkflow: jest.fn().mockResolvedValue(undefined), - deleteDataTable: jest.fn().mockResolvedValue(undefined), - listDataTables: jest.fn().mockResolvedValue([]), + const mocks: Record = { + 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({ diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/runner-workflow-checks.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/runner-workflow-checks.test.ts index a1ee8425305..b6a20584256 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/runner-workflow-checks.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/runner-workflow-checks.test.ts @@ -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', () => { diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/subagent-runner.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/subagent-runner.test.ts index 0e68f4c583f..ad012d12e84 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/subagent-runner.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/subagent-runner.test.ts @@ -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: [ diff --git a/packages/@n8n/instance-ai/evaluations/tsconfig.json b/packages/@n8n/instance-ai/evaluations/tsconfig.json index da39c047d88..dad9a2793b1 100644 --- a/packages/@n8n/instance-ai/evaluations/tsconfig.json +++ b/packages/@n8n/instance-ai/evaluations/tsconfig.json @@ -8,7 +8,7 @@ "lib": ["es2023"], "emitDecoratorMetadata": true, "experimentalDecorators": true, - "types": ["node", "jest"] + "types": ["node", "vitest/globals"] }, "include": ["**/*.ts"] } diff --git a/packages/@n8n/instance-ai/jest.config.js b/packages/@n8n/instance-ai/jest.config.js deleted file mode 100644 index d6c48554a79..00000000000 --- a/packages/@n8n/instance-ai/jest.config.js +++ /dev/null @@ -1,2 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = require('../../../jest.config'); diff --git a/packages/@n8n/instance-ai/package.json b/packages/@n8n/instance-ai/package.json index c137658e445..7ea1e5e09d4 100644 --- a/packages/@n8n/instance-ai/package.json +++ b/packages/@n8n/instance-ai/package.json @@ -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:" } } diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts index 819b550e2f6..8849ea31c51 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts @@ -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> = 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([ diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/mcp-tool-name-validation.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/mcp-tool-name-validation.test.ts index 9f7e7e65409..9b4818ae584 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/mcp-tool-name-validation.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/mcp-tool-name-validation.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/sanitize-mcp-schemas.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/sanitize-mcp-schemas.test.ts index 84a015be935..03d71b33f29 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/sanitize-mcp-schemas.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/sanitize-mcp-schemas.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/knowledge-base/__tests__/materialize-knowledge-base.test.ts b/packages/@n8n/instance-ai/src/knowledge-base/__tests__/materialize-knowledge-base.test.ts index 3c53d2d04ee..3828e1b9dfc 100644 --- a/packages/@n8n/instance-ai/src/knowledge-base/__tests__/materialize-knowledge-base.test.ts +++ b/packages/@n8n/instance-ai/src/knowledge-base/__tests__/materialize-knowledge-base.test.ts @@ -19,14 +19,14 @@ function createSandboxWorkspace(files: Map): { 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]); diff --git a/packages/@n8n/instance-ai/src/mcp/__tests__/mcp-client-manager.test.ts b/packages/@n8n/instance-ai/src/mcp/__tests__/mcp-client-manager.test.ts index 7d5c9550e71..8b9e3975535 100644 --- a/packages/@n8n/instance-ai/src/mcp/__tests__/mcp-client-manager.test.ts +++ b/packages/@n8n/instance-ai/src/mcp/__tests__/mcp-client-manager.test.ts @@ -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 { +function createValidatorMock(): Mocked { return { - validateUrl: jest.fn().mockResolvedValue(createResultOk(undefined)), - } as jest.Mocked; + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + } as Mocked; } 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. diff --git a/packages/@n8n/instance-ai/src/parsers/__tests__/docx-parser.test.ts b/packages/@n8n/instance-ai/src/parsers/__tests__/docx-parser.test.ts index c3fd0f018a1..51fc5038e07 100644 --- a/packages/@n8n/instance-ai/src/parsers/__tests__/docx-parser.test.ts +++ b/packages/@n8n/instance-ai/src/parsers/__tests__/docx-parser.test.ts @@ -1,9 +1,11 @@ import { extractDocxText } from '../docx-parser'; import { MAX_DECODED_SIZE_BYTES } from '../structured-file-parser'; -const mockExtractRawText = jest.fn, [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), diff --git a/packages/@n8n/instance-ai/src/parsers/__tests__/pdf-parser.test.ts b/packages/@n8n/instance-ai/src/parsers/__tests__/pdf-parser.test.ts index 6709dd23924..baf21ef4f61 100644 --- a/packages/@n8n/instance-ai/src/parsers/__tests__/pdf-parser.test.ts +++ b/packages/@n8n/instance-ai/src/parsers/__tests__/pdf-parser.test.ts @@ -1,17 +1,19 @@ import { extractPdfText } from '../pdf-parser'; import { MAX_DECODED_SIZE_BYTES } from '../structured-file-parser'; -const mockGetText = jest.fn, []>(); -const mockDestroy = jest.fn, []>(); - -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>(), })); +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'); diff --git a/packages/@n8n/instance-ai/src/parsers/__tests__/validate-attachments.test.ts b/packages/@n8n/instance-ai/src/parsers/__tests__/validate-attachments.test.ts index 9eb9d4cc99e..59cbd148916 100644 --- a/packages/@n8n/instance-ai/src/parsers/__tests__/validate-attachments.test.ts +++ b/packages/@n8n/instance-ai/src/parsers/__tests__/validate-attachments.test.ts @@ -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; diff --git a/packages/@n8n/instance-ai/src/parsers/structured-file-parser.ts b/packages/@n8n/instance-ai/src/parsers/structured-file-parser.ts index 9c7ffc72a53..276a8f41000 100644 --- a/packages/@n8n/instance-ai/src/parsers/structured-file-parser.ts +++ b/packages/@n8n/instance-ai/src/parsers/structured-file-parser.ts @@ -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; } diff --git a/packages/@n8n/instance-ai/src/planned-tasks/__tests__/planned-task-service.test.ts b/packages/@n8n/instance-ai/src/planned-tasks/__tests__/planned-task-service.test.ts index fb4988c271f..72c3843e393 100644 --- a/packages/@n8n/instance-ai/src/planned-tasks/__tests__/planned-task-service.test.ts +++ b/packages/@n8n/instance-ai/src/planned-tasks/__tests__/planned-task-service.test.ts @@ -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 { +function makeStorage(): Mocked { return { - get: jest.fn(), - save: jest.fn(), - update: jest.fn(), - clear: jest.fn(), - } as unknown as jest.Mocked; + get: vi.fn(), + save: vi.fn(), + update: vi.fn(), + clear: vi.fn(), + } as unknown as Mocked; } function makeTask(overrides: Partial = {}): PlannedTask { @@ -44,11 +46,11 @@ function makeTaskRecord(overrides: Partial = {}): PlannedTask } describe('PlannedTaskCoordinator', () => { - let storage: jest.Mocked; + let storage: Mocked; let coordinator: PlannedTaskCoordinator; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); storage = makeStorage(); coordinator = new PlannedTaskCoordinator(storage); }); diff --git a/packages/@n8n/instance-ai/src/runtime/__tests__/background-task-manager.test.ts b/packages/@n8n/instance-ai/src/runtime/__tests__/background-task-manager.test.ts index 3cb14aad339..968adad95e0 100644 --- a/packages/@n8n/instance-ai/src/runtime/__tests__/background-task-manager.test.ts +++ b/packages/@n8n/instance-ai/src/runtime/__tests__/background-task-manager.test.ts @@ -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(); 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(); 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(); 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(); 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(); manager.spawn( @@ -298,7 +298,7 @@ describe('BackgroundTaskManager', () => { ); expect(first.status).toBe('started'); - const run = jest.fn(async (): Promise => await new Promise(() => {})); + const run = vi.fn(async (): Promise => 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 => await new Promise(() => {})); + const run = vi.fn(async (): Promise => 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 => await new Promise(() => {})); + const run = vi.fn(async (): Promise => 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', diff --git a/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts b/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts index 8e047826b4b..e3d0be23aff 100644 --- a/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts +++ b/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts @@ -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(); const approval = createDeferred>(); const waitStarted = createDeferred(); - 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 }, diff --git a/packages/@n8n/instance-ai/src/runtime/__tests__/run-state-registry.test.ts b/packages/@n8n/instance-ai/src/runtime/__tests__/run-state-registry.test.ts index ff2a1dbbcf6..28a27188256 100644 --- a/packages/@n8n/instance-ai/src/runtime/__tests__/run-state-registry.test.ts +++ b/packages/@n8n/instance-ai/src/runtime/__tests__/run-state-registry.test.ts @@ -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, diff --git a/packages/@n8n/instance-ai/src/runtime/__tests__/stream-runner.test.ts b/packages/@n8n/instance-ai/src/runtime/__tests__/stream-runner.test.ts index c62ce04631b..b9f33892133 100644 --- a/packages/@n8n/instance-ai/src/runtime/__tests__/stream-runner.test.ts +++ b/packages/@n8n/instance-ai/src/runtime/__tests__/stream-runner.test.ts @@ -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('../resumable-stream-executor'); +vi.mock('../resumable-stream-executor', async () => { + const actual = await vi.importActual( + '../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) { 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({ @@ -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(); diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts index bcca4fd97e7..246105c58ff 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts @@ -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(); - 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>, - Parameters> + const executeCommand = vi.fn< + ( + ...args: Parameters> + ) => ReturnType> >( 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', diff --git a/packages/@n8n/instance-ai/src/source-map-filter.ts b/packages/@n8n/instance-ai/src/source-map-filter.ts index 8e94a2576ea..840c6c841b6 100644 --- a/packages/@n8n/instance-ai/src/source-map-filter.ts +++ b/packages/@n8n/instance-ai/src/source-map-filter.ts @@ -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 { diff --git a/packages/@n8n/instance-ai/src/storage/__tests__/planned-task-storage.test.ts b/packages/@n8n/instance-ai/src/storage/__tests__/planned-task-storage.test.ts index b7143953220..7a1057963c2 100644 --- a/packages/@n8n/instance-ai/src/storage/__tests__/planned-task-storage.test.ts +++ b/packages/@n8n/instance-ai/src/storage/__tests__/planned-task-storage.test.ts @@ -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('../thread-patch'); +vi.mock('../thread-patch', async () => { + const actual = await vi.importActual('../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); }); diff --git a/packages/@n8n/instance-ai/src/storage/__tests__/terminal-outcome-storage.test.ts b/packages/@n8n/instance-ai/src/storage/__tests__/terminal-outcome-storage.test.ts index 3afd589df0d..185fed8472f 100644 --- a/packages/@n8n/instance-ai/src/storage/__tests__/terminal-outcome-storage.test.ts +++ b/packages/@n8n/instance-ai/src/storage/__tests__/terminal-outcome-storage.test.ts @@ -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); }); diff --git a/packages/@n8n/instance-ai/src/storage/__tests__/thread-iteration-log-storage.test.ts b/packages/@n8n/instance-ai/src/storage/__tests__/thread-iteration-log-storage.test.ts index c444bf1fe4f..a12f01089ec 100644 --- a/packages/@n8n/instance-ai/src/storage/__tests__/thread-iteration-log-storage.test.ts +++ b/packages/@n8n/instance-ai/src/storage/__tests__/thread-iteration-log-storage.test.ts @@ -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('../thread-patch'); +vi.mock('../thread-patch', async () => { + const actual = await vi.importActual('../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: { diff --git a/packages/@n8n/instance-ai/src/storage/__tests__/thread-patch.test.ts b/packages/@n8n/instance-ai/src/storage/__tests__/thread-patch.test.ts index 027462bd5e6..e6160fceb94 100644 --- a/packages/@n8n/instance-ai/src/storage/__tests__/thread-patch.test.ts +++ b/packages/@n8n/instance-ai/src/storage/__tests__/thread-patch.test.ts @@ -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 { 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' }; }); diff --git a/packages/@n8n/instance-ai/src/storage/__tests__/thread-task-storage.test.ts b/packages/@n8n/instance-ai/src/storage/__tests__/thread-task-storage.test.ts index 4fae7e13ebb..41d92422455 100644 --- a/packages/@n8n/instance-ai/src/storage/__tests__/thread-task-storage.test.ts +++ b/packages/@n8n/instance-ai/src/storage/__tests__/thread-task-storage.test.ts @@ -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('../thread-patch'); +vi.mock('../thread-patch', async () => { + const actual = await vi.importActual('../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' }, diff --git a/packages/@n8n/instance-ai/src/storage/__tests__/workflow-loop-storage.test.ts b/packages/@n8n/instance-ai/src/storage/__tests__/workflow-loop-storage.test.ts index ace43a04d22..dc1032f3e6d 100644 --- a/packages/@n8n/instance-ai/src/storage/__tests__/workflow-loop-storage.test.ts +++ b/packages/@n8n/instance-ai/src/storage/__tests__/workflow-loop-storage.test.ts @@ -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('../thread-patch'); +vi.mock('../thread-patch', async () => { + const actual = await vi.importActual('../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: {}, }); diff --git a/packages/@n8n/instance-ai/src/stream/__tests__/consume-with-hitl.test.ts b/packages/@n8n/instance-ai/src/stream/__tests__/consume-with-hitl.test.ts index 6b7ba25f19a..880f7755a5e 100644 --- a/packages/@n8n/instance-ai/src/stream/__tests__/consume-with-hitl.test.ts +++ b/packages/@n8n/instance-ai/src/stream/__tests__/consume-with-hitl.test.ts @@ -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'); diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts index 034dc809531..71161aac0a5 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts @@ -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; 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( diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/data-tables.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/data-tables.tool.test.ts index 3b75915f4b7..a9af9deb0e7 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/data-tables.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/data-tables.tool.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/executions.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/executions.tool.test.ts index 1f7de07d3bd..62a14a7266d 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/executions.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/executions.tool.test.ts @@ -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( diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/index.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/index.test.ts index 7d71b1da85e..123c2a0edb8 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/index.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/index.test.ts @@ -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 { 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' }], }); diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/nodes.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/nodes.tool.test.ts index 4887e0bc26c..a7797593091 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/nodes.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/nodes.tool.test.ts @@ -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 = {}): 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( diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/research.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/research.tool.test.ts index cf1fa261493..2cc0ff3ec9c 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/research.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/research.tool.test.ts @@ -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; diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/task-control.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/task-control.tool.test.ts index 426fbf64718..f4dc32aabaf 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/task-control.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/task-control.tool.test.ts @@ -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 = {}): 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( diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/templates.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/templates.tool.test.ts index 5efede2efe9..02fd88e2b26 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/templates.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/templates.tool.test.ts @@ -3,7 +3,7 @@ import { createTemplatesTool } from '../templates.tool'; describe('templates tool', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe('best-practices action', () => { diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index 7258fc66006..e9c7308c4a7 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -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' }, { diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workspace.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workspace.tool.test.ts index 2725b088583..c1713007247 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workspace.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workspace.tool.test.ts @@ -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, }); diff --git a/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts b/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts index 1b6edfc7fe2..0b425a83de2 100644 --- a/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts @@ -14,51 +14,51 @@ function createMockContext(overrides?: Partial): 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, }; diff --git a/packages/@n8n/instance-ai/src/tools/evals/__tests__/create-empty-eval-data-table.test.ts b/packages/@n8n/instance-ai/src/tools/evals/__tests__/create-empty-eval-data-table.test.ts index aa4781c61fb..9d09022e611 100644 --- a/packages/@n8n/instance-ai/src/tools/evals/__tests__/create-empty-eval-data-table.test.ts +++ b/packages/@n8n/instance-ai/src/tools/evals/__tests__/create-empty-eval-data-table.test.ts @@ -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, - Parameters + ( + ...args: Parameters + ) => ReturnType >() .mockResolvedValue(dataTableSummary('dt-1', 'Wf — eval samples')); - const insertRows = jest.fn< - ReturnType, - Parameters - >(); + const insertRows = + vi.fn< + ( + ...args: Parameters + ) => ReturnType + >(); 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, - Parameters + ( + ...args: Parameters + ) => ReturnType >() .mockResolvedValue(dataTableSummary('dt-1', 'Wf — eval samples')); const ctx = createContext({ create, - insertRows: jest.fn< - ReturnType, - Parameters - >(), + insertRows: + vi.fn< + ( + ...args: Parameters + ) => ReturnType + >(), }); 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, - Parameters + ( + ...args: Parameters + ) => ReturnType >() .mockRejectedValueOnce(new Error('Data table already exists')) .mockResolvedValueOnce(dataTableSummary('dt-2', 'Wf — eval samples (abc12)')); const ctx = createContext({ create, - insertRows: jest.fn< - ReturnType, - Parameters - >(), + insertRows: + vi.fn< + ( + ...args: Parameters + ) => ReturnType + >(), }); 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, - Parameters + ( + ...args: Parameters + ) => ReturnType >() .mockRejectedValueOnce(new Error('database down')); const ctx = createContext({ create, - insertRows: jest.fn< - ReturnType, - Parameters - >(), + insertRows: + vi.fn< + ( + ...args: Parameters + ) => ReturnType + >(), }); await expect( createEmptyEvalDataTable(ctx, { workflowName: 'Wf', columns: ['x'] }), diff --git a/packages/@n8n/instance-ai/src/tools/evals/__tests__/evals.tool.test.ts b/packages/@n8n/instance-ai/src/tools/evals/__tests__/evals.tool.test.ts index 4611cf7fe98..1c8437ddd6b 100644 --- a/packages/@n8n/instance-ai/src/tools/evals/__tests__/evals.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/evals/__tests__/evals.tool.test.ts @@ -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, [SelectMetricsSuspendPayload]>() + const suspend = vi + .fn<(...args: [SelectMetricsSuspendPayload]) => Promise>() .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); diff --git a/packages/@n8n/instance-ai/src/tools/evals/__tests__/extract-rows-from-history.service.test.ts b/packages/@n8n/instance-ai/src/tools/evals/__tests__/extract-rows-from-history.service.test.ts index 6b4bfb78485..51c4ffb4b08 100644 --- a/packages/@n8n/instance-ai/src/tools/evals/__tests__/extract-rows-from-history.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/evals/__tests__/extract-rows-from-history.service.test.ts @@ -10,13 +10,17 @@ const buildContext = ( ): InstanceAiContext => ({ executionService: { - list: jest - .fn, Parameters>() + list: vi + .fn< + (...args: Parameters) => ReturnType + >() .mockResolvedValue([]), - getNodeOutput: jest.fn< - ReturnType, - Parameters - >(), + getNodeOutput: + vi.fn< + ( + ...args: Parameters + ) => ReturnType + >(), ...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, Parameters>() - .mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]), - getNodeOutput: jest + list: vi .fn< - ReturnType, - Parameters + (...args: Parameters) => ReturnType + >() + .mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]), + getNodeOutput: vi + .fn< + ( + ...args: Parameters + ) => ReturnType >() .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, Parameters>() + list: vi + .fn< + (...args: Parameters) => ReturnType + >() .mockResolvedValueOnce([ executionSummary('e1'), executionSummary('e2'), executionSummary('e3'), ]), - getNodeOutput: jest + getNodeOutput: vi .fn< - ReturnType, - Parameters + ( + ...args: Parameters + ) => ReturnType >() .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, Parameters>() - .mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]), - getNodeOutput: jest + list: vi .fn< - ReturnType, - Parameters + (...args: Parameters) => ReturnType + >() + .mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]), + getNodeOutput: vi + .fn< + ( + ...args: Parameters + ) => ReturnType >() .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, Parameters>() - .mockResolvedValueOnce([executionSummary('e1')]), - getNodeOutput: jest + list: vi .fn< - ReturnType, - Parameters + (...args: Parameters) => ReturnType + >() + .mockResolvedValueOnce([executionSummary('e1')]), + getNodeOutput: vi + .fn< + ( + ...args: Parameters + ) => ReturnType >() .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, Parameters>() + list: vi + .fn< + (...args: Parameters) => ReturnType + >() .mockResolvedValueOnce(summaries), - getNodeOutput: jest.fn< - ReturnType, - Parameters + getNodeOutput: vi.fn< + ( + ...args: Parameters + ) => ReturnType >(async () => await Promise.resolve(outputs.shift() ?? nodeOutput('Trigger', {}))), }); @@ -213,15 +232,16 @@ describe('extractRowsFromExecutionHistory', () => { }); it('lists only successful executions', async () => { - const list = jest - .fn, Parameters>() + const list = vi + .fn<(...args: Parameters) => ReturnType>() .mockResolvedValue([executionSummary('e1')]); const ctx = buildContext({ list, - getNodeOutput: jest + getNodeOutput: vi .fn< - ReturnType, - Parameters + ( + ...args: Parameters + ) => ReturnType >() .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, Parameters>() - .mockResolvedValueOnce([executionSummary('e1')]), - getNodeOutput: jest + list: vi .fn< - ReturnType, - Parameters + (...args: Parameters) => ReturnType + >() + .mockResolvedValueOnce([executionSummary('e1')]), + getNodeOutput: vi + .fn< + ( + ...args: Parameters + ) => ReturnType >() .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, Parameters>() + list: vi + .fn< + (...args: Parameters) => ReturnType + >() .mockResolvedValueOnce([executionSummary('e1')]), - getNodeOutput: jest.fn< - ReturnType, - Parameters + getNodeOutput: vi.fn< + ( + ...args: Parameters + ) => ReturnType >( 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, Parameters>() + list: vi + .fn< + (...args: Parameters) => ReturnType + >() .mockResolvedValueOnce([executionSummary('e1')]), - getNodeOutput: jest.fn< - ReturnType, - Parameters + getNodeOutput: vi.fn< + ( + ...args: Parameters + ) => ReturnType >( async (_id, nodeName) => await Promise.resolve( diff --git a/packages/@n8n/instance-ai/src/tools/evals/__tests__/generate-sample-rows.service.test.ts b/packages/@n8n/instance-ai/src/tools/evals/__tests__/generate-sample-rows.service.test.ts index 2d51c1f4bd5..f00ab937d70 100644 --- a/packages/@n8n/instance-ai/src/tools/evals/__tests__/generate-sample-rows.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/evals/__tests__/generate-sample-rows.service.test.ts @@ -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; -const mockExtractText = extractText as jest.MockedFunction; +const mockCreateEvalAgent = createEvalAgent as MockedFunction; +const mockExtractText = extractText as MockedFunction; 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, [string]>; +type GenerateMock = Mock<(...args: [string]) => Promise<{ messages: [] }>>; function createGenerateMock(): GenerateMock { - return jest.fn, [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?]>() }; + const logger = { warn: vi.fn<(a: string, b?: Record) => 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 >); diff --git a/packages/@n8n/instance-ai/src/tools/evals/__tests__/generate-tool-ref-pin-data.service.test.ts b/packages/@n8n/instance-ai/src/tools/evals/__tests__/generate-tool-ref-pin-data.service.test.ts index 27d0f70d4e6..e275d068948 100644 --- a/packages/@n8n/instance-ai/src/tools/evals/__tests__/generate-tool-ref-pin-data.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/evals/__tests__/generate-tool-ref-pin-data.service.test.ts @@ -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; -const mockExtractText = extractText as jest.MockedFunction; +const mockCreateEvalAgent = createEvalAgent as MockedFunction; +const mockExtractText = extractText as MockedFunction; 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 >); diff --git a/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts b/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts index 6caeca26e91..7844d2d4fa1 100644 --- a/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts +++ b/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts @@ -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 { +function makeMockServer(tools: McpTool[] = [SAMPLE_TOOL]): Mocked { 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 | 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: 'TOOL' }, { ...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, diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/agent-persistence.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/agent-persistence.test.ts index 9567bf68856..c2f9894ff4b 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/agent-persistence.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/agent-persistence.test.ts @@ -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'); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/complete-checkpoint.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/complete-checkpoint.tool.test.ts index 5c44a7666f1..9fa10074795 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/complete-checkpoint.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/complete-checkpoint.tool.test.ts @@ -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 { 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' } }), }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/delegate.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/delegate.tool.test.ts index b461dc0a57f..ed01cfd0c1e 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/delegate.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/delegate.tool.test.ts @@ -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 = {}): 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 = {}): Orchestra ), abortSignal: new AbortController().signal, taskStorage: { - get: jest.fn(), - save: jest.fn(), + get: vi.fn(), + save: vi.fn(), } as TaskStorage, }; } diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/eval-data-agent.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/eval-data-agent.tool.test.ts index 3fd9d0e570a..a47eaffdba4 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/eval-data-agent.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/eval-data-agent.tool.test.ts @@ -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' }]); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/eval-setup-agent.tools.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/eval-setup-agent.tools.test.ts index 5941f2c538d..20a991705fc 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/eval-setup-agent.tools.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/eval-setup-agent.tools.test.ts @@ -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(); 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(); 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', diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan.tool.test.ts index 1dfe325b435..6d9721ca5f5 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan.tool.test.ts @@ -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 { 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 = {}): 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?]>; + const warnMock = context.logger.warn as Mock< + (...args: [string, Record?]) => 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); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/planner-briefing.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/planner-briefing.test.ts index 9844a6f4719..0ba16761e1a 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/planner-briefing.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/planner-briefing.test.ts @@ -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' }], }), }, diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/planner-run-coordinator.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/planner-run-coordinator.test.ts index 2f9661eb82e..86cea53721f 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/planner-run-coordinator.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/planner-run-coordinator.test.ts @@ -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; }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/report-verification-verdict.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/report-verification-verdict.tool.test.ts index 118ebfa49b9..fc4623f5c98 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/report-verification-verdict.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/report-verification-verdict.tool.test.ts @@ -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, [unknown]>(); + const reportVerificationVerdict = vi.fn<(...args: [unknown]) => Promise>(); reportVerificationVerdict.mockResolvedValue(action); return reportVerificationVerdict; } @@ -29,19 +29,19 @@ function createMockContext(overrides: Partial = {}): 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), }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/tracing-utils.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/tracing-utils.test.ts index 53cbaf6faa8..536e985f5e1 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/tracing-utils.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/tracing-utils.test.ts @@ -1,22 +1,21 @@ +/* eslint-disable import-x/order */ import type { OrchestrationContext } from '../../../types'; -const mockCreateDetachedSubAgentTraceContext = jest.fn, [unknown]>(); +const mockCreateDetachedSubAgentTraceContext = vi.fn<(arg: unknown) => Promise>(); -jest.mock('../../../tracing/langsmith-tracing', () => ({ +vi.mock('../../../tracing/langsmith-tracing', () => ({ createDetachedSubAgentTraceContext: async (options: unknown): Promise => 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' }, diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/verify-built-workflow.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/verify-built-workflow.tool.test.ts index f513691ad64..21f0208551d 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/verify-built-workflow.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/verify-built-workflow.tool.test.ts @@ -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 { 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 = {}): 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 = {}): 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 = {}): 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 = {}): 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 = {}): 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, - [string, Record | undefined, { timeout?: number; pinData?: unknown }] + run: Mock< + ( + ...args: [ + string, + Record | undefined, + { timeout?: number; pinData?: unknown }, + ] + ) => Promise >; }; 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 { @@ -399,12 +406,12 @@ function makeContext( snapshotErrors?: Record; } = {}, ) { - const updateBuildOutcome = jest.fn( + const updateBuildOutcome = vi.fn( async (_workItemId: string, _update: Partial) => { await Promise.resolve(); }, ); - const run = jest.fn( + const run = vi.fn( async ( _workflowId: string, _inputData: Record | undefined, @@ -421,7 +428,7 @@ function makeContext( * after the snapshot phase for a given table switches to `queriesAfterRun`. */ const snapshotDone = new Set(); - 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: { diff --git a/packages/@n8n/instance-ai/src/tools/research.tool.ts b/packages/@n8n/instance-ai/src/tools/research.tool.ts index a59f5088507..165c6140300 100644 --- a/packages/@n8n/instance-ai/src/tools/research.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/research.tool.ts @@ -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, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts index 8789e26d6b4..2adf39b960a 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts @@ -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, - [string, string, { result?: string; outcome?: WorkflowBuildOutcome }] + const markSucceeded = vi.fn< + ( + threadId: string, + taskId: string, + update: { result?: string; outcome?: WorkflowBuildOutcome }, + ) => Promise >(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' }, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts index 3aee0366358..1d256a9b0b9 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts @@ -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' }, ]); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts index dc951b9f269..62a366df557 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts @@ -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): 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).getNodeCredentialTypes = jest + (context.nodeService as unknown as Record).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).getNodeCredentialTypes = jest + (context.nodeService as unknown as Record).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).getNodeCredentialTypes = jest + (context.nodeService as unknown as Record).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).getNodeCredentialTypes = jest + (context.nodeService as unknown as Record).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).getNodeCredentialTypes = jest + (context.nodeService as unknown as Record).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).getNodeCredentialTypes = jest + (context.nodeService as unknown as Record).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).getParameterIssues = jest + (context.nodeService as unknown as Record).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).getNodeCredentialTypes = jest + (context.nodeService as unknown as Record).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).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).getNodeCredentialTypes = jest + (context.nodeService as unknown as Record).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).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).getParameterIssues = jest + (context.nodeService as unknown as Record).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).getParameterIssues = jest + (context.nodeService as unknown as Record).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' }, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts index 2bbcb167c51..98a1cc65e0b 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts @@ -211,11 +211,11 @@ describe('wrapSubmitExecuteWithIdentity', () => { }); it('blocks submit when persisted remediation says editing must stop', async () => { - const execute = jest.fn(async (): Promise => { + const execute = vi.fn(async (): Promise => { 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, [SubmitWorkflowInput]>() + const execute = vi + .fn<(...args: [SubmitWorkflowInput]) => Promise>() .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 => { + const execute = vi.fn(async (): Promise => { 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 => { + const execute = vi.fn(async (): Promise => { await Promise.resolve(); return { success: true, workflowId: 'wf_current' }; }); @@ -379,12 +379,12 @@ describe('wrapSubmitExecuteWithIdentity', () => { successfulSubmitSeen: true, postSubmitRemediationSubmitsUsed: 2, }; - const getWorkflowLoopState = jest - .fn, []>() + const getWorkflowLoopState = vi + .fn<(...args: []) => Promise>() .mockResolvedValueOnce(undefined) .mockResolvedValueOnce(undefined) .mockResolvedValueOnce(terminalState); - const execute = jest.fn(async (input: SubmitWorkflowInput): Promise => { + const execute = vi.fn(async (input: SubmitWorkflowInput): Promise => { await gate; return { success: true, workflowId: input.workflowId ?? 'wf_1' }; }); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow.tool.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow.tool.test.ts index 6520652c6c1..172802b250d 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow.tool.test.ts @@ -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'); }), diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/validate-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/validate-workflow.service.test.ts index 35da8e5660b..6ae39e052f9 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/validate-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/validate-workflow.service.test.ts @@ -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): 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, ): void { - (context.nodeService as unknown as Record).getResolvedNodeInputs = jest + (context.nodeService as unknown as Record).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 | null, ): void { - (context.workflowService as unknown as Record).getLatestRunData = jest + (context.workflowService as unknown as Record).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 | null, ): void { - (context.workflowService as unknown as Record).getLatestRunData = jest + (context.workflowService as unknown as Record).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, { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/write-sandbox-file.tool.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/write-sandbox-file.tool.test.ts index 24bac0187c1..e3ebd6a6c10 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/write-sandbox-file.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/write-sandbox-file.tool.test.ts @@ -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; -const mockGetRoot = getWorkspaceRoot as jest.MockedFunction; +const mockWriteFile = writeFileViaSandbox as MockedFunction; +const mockGetRoot = getWorkspaceRoot as MockedFunction; // --------------------------------------------------------------------------- // Helpers @@ -26,7 +28,7 @@ const mockGetRoot = getWorkspaceRoot as jest.MockedFunction { let workspace: SandboxWorkspace; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); workspace = createMockWorkspace(); mockGetRoot.mockResolvedValue('/home/user/workspace'); }); diff --git a/packages/@n8n/instance-ai/src/tracing/__tests__/langsmith-tracing.test.ts b/packages/@n8n/instance-ai/src/tracing/__tests__/langsmith-tracing.test.ts index b9cee92202f..580f68a8375 100644 --- a/packages/@n8n/instance-ai/src/tracing/__tests__/langsmith-tracing.test.ts +++ b/packages/@n8n/instance-ai/src/tracing/__tests__/langsmith-tracing.test.ts @@ -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>('@n8n/agents'); - const { AsyncLocalStorage } = jest.requireActual('node:async_hooks'); - const { ROOT_CONTEXT, context, trace } = jest.requireActual<{ +vi.mock('@n8n/agents', async () => { + const actual = await vi.importActual>('@n8n/agents'); + const { AsyncLocalStorage } = await vi.importActual('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, []>; - shutdown: jest.Mock, []>; + forceFlush: Mock<(...args: []) => Promise>; + shutdown: Mock<(...args: []) => Promise>; }; }; }; @@ -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>>, ) { @@ -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', diff --git a/packages/@n8n/instance-ai/src/utils/__tests__/stream-helpers.test.ts b/packages/@n8n/instance-ai/src/utils/__tests__/stream-helpers.test.ts index 9f6256984d4..55d4a112cde 100644 --- a/packages/@n8n/instance-ai/src/utils/__tests__/stream-helpers.test.ts +++ b/packages/@n8n/instance-ai/src/utils/__tests__/stream-helpers.test.ts @@ -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, diff --git a/packages/@n8n/instance-ai/src/workflow-builder/__tests__/parse-validate.test.ts b/packages/@n8n/instance-ai/src/workflow-builder/__tests__/parse-validate.test.ts index 2cd6fb702b2..5f7712aad9d 100644 --- a/packages/@n8n/instance-ai/src/workflow-builder/__tests__/parse-validate.test.ts +++ b/packages/@n8n/instance-ai/src/workflow-builder/__tests__/parse-validate.test.ts @@ -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 = {}) { 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: [], }), diff --git a/packages/@n8n/instance-ai/src/workflow-loop/__tests__/workflow-task-service.test.ts b/packages/@n8n/instance-ai/src/workflow-loop/__tests__/workflow-task-service.test.ts index 2b1130f40e5..38454229365 100644 --- a/packages/@n8n/instance-ai/src/workflow-loop/__tests__/workflow-task-service.test.ts +++ b/packages/@n8n/instance-ai/src/workflow-loop/__tests__/workflow-task-service.test.ts @@ -6,14 +6,14 @@ function createStorage() { const records = new Map>(); 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 >, ); }), - saveWorkItem: jest.fn( + saveWorkItem: vi.fn( async ( _threadId: string, state: Record, diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/builder-templates-service.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/builder-templates-service.test.ts index 7d20b9c8601..2a29b5c4afa 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/builder-templates-service.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/builder-templates-service.test.ts @@ -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); @@ -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( diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts index 7587a6b5a53..4e76c5a6345 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/daytona-auth-manager.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/daytona-auth-manager.test.ts index b79b4cf6a28..f85cf120232 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/daytona-auth-manager.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/daytona-auth-manager.test.ts @@ -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, []>().mockImplementation(async () => { + const getAuthToken = vi.fn<(...args: []) => Promise>().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((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, []>().mockImplementation(async () => { + const getAuthToken = vi.fn<(...args: []) => Promise>().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({ diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/daytona-sandbox.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/daytona-sandbox.test.ts index 598011b9b34..c174051739c 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/daytona-sandbox.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/daytona-sandbox.test.ts @@ -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; - 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; + start: Mock; + stop: Mock; + delete: Mock; + getWorkDir: Mock; } interface DaytonaClientLog { id: number; config: unknown; - get: jest.Mock, [string]>; - create: jest.Mock, [unknown, unknown?]>; - delete: jest.Mock; + get: Mock<(...args: [string]) => Promise>; + create: Mock<(...args: [unknown, unknown?]) => Promise>; + delete: Mock; } -const clientLog: DaytonaClientLog[] = []; -let nextClientId = 1; -let nextSandboxId = 1; -const queuedGetErrors: Error[] = []; -const queuedCreateResults: Array = []; +// 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 = []; -// 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, [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, [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>() + .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>() + .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('@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, []>().mockImplementation(async () => { - await Promise.resolve(); - return makeJwt(Date.now() + HOUR_MS); - }); + const getAuthToken = vi + .fn<(...args: []) => Promise>() + .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, []>().mockImplementation(async () => { - await Promise.resolve(); - return makeJwt(Date.now() + HOUR_MS); - }); + const getAuthToken = vi + .fn<(...args: []) => Promise>() + .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, []>().mockImplementation(async () => { + vi.useFakeTimers().setSystemTime(new Date(1_700_000_000_000)); + const getAuthToken = vi.fn<(...args: []) => Promise>().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 () => { diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts index aff0d48aabd..c2ccc8f968b 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts @@ -8,9 +8,8 @@ import { import { createLazyRuntimeWorkspace } from '../lazy-runtime-workspace'; function createMockWorkspace() { - const executeCommand = jest.fn< - Promise, - Parameters> + const executeCommand = vi.fn< + (...args: Parameters>) => Promise >( 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'); diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/n8n-sandbox-sandbox.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/n8n-sandbox-sandbox.test.ts index aad349a41b1..8bd005e676b 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/n8n-sandbox-sandbox.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/n8n-sandbox-sandbox.test.ts @@ -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 = {}) { // --------------------------------------------------------------------------- beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); mockCreateSandbox.mockResolvedValue(makeSandboxRecord()); mockGetSandbox.mockResolvedValue(makeSandboxRecord()); mockDeleteSandbox.mockResolvedValue(undefined); diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/prebaked-workspace-bundle.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/prebaked-workspace-bundle.test.ts index b44ef9d759e..b4d797c157e 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/prebaked-workspace-bundle.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/prebaked-workspace-bundle.test.ts @@ -15,14 +15,14 @@ function createSandboxWorkspace(files: Map): { 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()), }, }; diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-fs.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-fs.test.ts index 07d174a5fb2..dd17748ad3f 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-fs.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-fs.test.ts @@ -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: '', diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts index 9998afa128d..f56c3cfc22f 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts @@ -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; -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, [SandboxWorkspace, string]>; +type ReadFileViaSandboxMock = Mock<(...args: [SandboxWorkspace, string]) => Promise>; 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, [string, string | Buffer, { recursive?: boolean }?]>, - mkdir?: jest.Mock, [string, { recursive?: boolean }?]>, + writeFile: Mock<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise>, + mkdir?: Mock<(...args: [string, { recursive?: boolean }?]) => Promise>, ): SandboxWorkspace { return { filesystem: { provider: 'local', basePath: '/sandbox', writeFile, - mkdir: mkdir ?? jest.fn, [string, { recursive?: boolean }?]>(async () => {}), + mkdir: + mkdir ?? + vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise>(async () => {}), }, }; } -function loadSetupSandboxWorkspaceWithFsMocks( +async function loadSetupSandboxWorkspaceWithFsMocks( runInSandbox: RunInSandboxMock, readFileViaSandbox: ReadFileViaSandboxMock, -): SetupSandboxWorkspace { - jest.resetModules(); - jest.doMock('../sandbox-fs', () => ({ +): Promise { + 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 { + 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; devDependencies: Record; -} { - 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; @@ -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, - [SandboxWorkspace, string] - >(); + const readFileViaSandbox: ReadFileViaSandboxMock = + vi.fn<(...args: [SandboxWorkspace, string]) => Promise>(); readFileViaSandbox.mockResolvedValue(null); - const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks( runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( - async () => {}, - ); + const writeFile = vi.fn< + (...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise + >(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, - [SandboxWorkspace, string] - >(); + const readFileViaSandbox: ReadFileViaSandboxMock = + vi.fn<(...args: [SandboxWorkspace, string]) => Promise>(); readFileViaSandbox.mockResolvedValue(null); - const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks( runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( + const writeFile = vi.fn< + (...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise + >(async () => {}); + const mkdir = vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise>( async () => {}, ); - const mkdir = jest.fn, [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, - [SandboxWorkspace, string] - >(); + const readFileViaSandbox: ReadFileViaSandboxMock = + vi.fn<(...args: [SandboxWorkspace, string]) => Promise>(); readFileViaSandbox.mockResolvedValue(null); - const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks( runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( - async () => {}, - ); + const writeFile = vi.fn< + (...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise + >(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, - [SandboxWorkspace, string] - >(); + const readFileViaSandbox: ReadFileViaSandboxMock = + vi.fn<(...args: [SandboxWorkspace, string]) => Promise>(); readFileViaSandbox.mockResolvedValue(null); - const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks( runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( - async () => {}, - ); + const writeFile = vi.fn< + (...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise + >(async () => {}); const context = createSetupContext(); const workflowService = context.workflowService as unknown as { - list: jest.Mock>, [{ limit: number }]>; - get: jest.Mock>, [string]>; + list: Mock<(...args: [{ limit: number }]) => Promise>>; + get: Mock<(...args: [string]) => Promise>>; }; 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, - [SandboxWorkspace, string] - >(); + const readFileViaSandbox: ReadFileViaSandboxMock = + vi.fn<(...args: [SandboxWorkspace, string]) => Promise>(); readFileViaSandbox.mockResolvedValue(null); - const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks( runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( - async () => {}, - ); + const writeFile = vi.fn< + (...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise + >(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, - [SandboxWorkspace, string] - >(); + const readFileViaSandbox: ReadFileViaSandboxMock = + vi.fn<(...args: [SandboxWorkspace, string]) => Promise>(); readFileViaSandbox.mockResolvedValue(null); - const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks( runInSandbox, readFileViaSandbox, ); - const writeFile = jest - .fn, [string, string | Buffer, { recursive?: boolean }?]>() + const writeFile = vi + .fn<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise>() .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, - [SandboxWorkspace, string] - >(); + const readFileViaSandbox: ReadFileViaSandboxMock = + vi.fn<(...args: [SandboxWorkspace, string]) => Promise>(); readFileViaSandbox.mockResolvedValue(null); - const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks( runInSandbox, readFileViaSandbox, ); - const writeFile = jest - .fn, [string, string | Buffer, { recursive?: boolean }?]>() + const writeFile = vi + .fn<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise>() .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, [string, Buffer, { recursive?: boolean }?]>( + const linkWorkspaceSdkIfEnabled = await loadLinkWorkspaceSdkWithMocks( + packWorkspaceSdk, + runInSandbox, + ); + const writeFile = vi.fn<(...args: [string, Buffer, { recursive?: boolean }?]) => Promise>( 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, []>(async () => { + const executeCommand = vi.fn(); + const init = vi.fn<(...args: []) => Promise>(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; type FsMocks = { runInSandbox: RunInSandboxMock; - writeFileViaSandbox: jest.Mock, [SandboxWorkspace, string, string | Buffer]>; + writeFileViaSandbox: Mock< + (...args: [SandboxWorkspace, string, string | Buffer]) => Promise + >; }; - 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, [SandboxWorkspace, string, string | Buffer]>( - async () => {}, - ); - jest.resetModules(); - jest.doMock('../sandbox-fs', () => ({ + const writeFileViaSandbox = vi.fn< + (...args: [SandboxWorkspace, string, string | Buffer]) => Promise + >(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, [string, Buffer, { recursive?: boolean }?]>(async () => {}), - mkdir: jest.fn, [string, { recursive?: boolean }?]>(async () => {}), + writeFile: vi.fn<(...args: [string, Buffer, { recursive?: boolean }?]) => Promise>( + async () => {}, + ), + mkdir: vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise>(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, [string, string | Buffer, { recursive?: boolean }?]>( - async () => {}, - ); + const { fn, fs } = await loadWriteCuratedExamples(); + const writeFile = vi.fn< + (...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise + >(async () => {}); const workspace = { filesystem: { provider: 'local', basePath: '/sandbox', writeFile, - mkdir: jest.fn, [string, { recursive?: boolean }?]>(async () => {}), + mkdir: vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise>( + async () => {}, + ), }, } as unknown as SandboxWorkspace; diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/scoped-workspace.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/scoped-workspace.test.ts index 10f9869b2ba..c449224f17a 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/scoped-workspace.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/scoped-workspace.test.ts @@ -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 = {}): 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 = {}): 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); diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts index 3a9f6583380..0b050217c69 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts @@ -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, [string]>; - create: jest.Mock, [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, [string]>(), - create: jest.fn, [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)); diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/workspace-files.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/workspace-files.test.ts index 77d7f10cbd7..f4aa255d218 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/workspace-files.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/workspace-files.test.ts @@ -8,18 +8,18 @@ function createWorkspaceTarget(files: Map): { const writes = new Map(); 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}'); diff --git a/packages/@n8n/instance-ai/tsconfig.json b/packages/@n8n/instance-ai/tsconfig.json index de41bb69bfd..1d6699a5e8e 100644 --- a/packages/@n8n/instance-ai/tsconfig.json +++ b/packages/@n8n/instance-ai/tsconfig.json @@ -12,7 +12,7 @@ "@/*": ["./src/*"] }, "tsBuildInfoFile": "dist/typecheck.tsbuildinfo", - "types": ["node", "jest"] + "types": ["node", "vitest/globals"] }, "include": ["src/**/*.ts"] } diff --git a/packages/@n8n/instance-ai/vite.config.ts b/packages/@n8n/instance-ai/vite.config.ts new file mode 100644 index 00000000000..4531a4f3d69 --- /dev/null +++ b/packages/@n8n/instance-ai/vite.config.ts @@ -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', + ), + }, + ], + }, + }, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 793853e8c13..fb7872ac005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: