mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 02:37:46 +02:00
chore: Migrate instance-ai from Jest to Vitest (#31463)
This commit is contained in:
parent
24f27ed559
commit
25766222b8
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
jest.mock('fs', () => ({
|
||||
readdirSync: jest.fn(),
|
||||
readFileSync: jest.fn(),
|
||||
/* eslint-disable import-x/order */
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
readdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
|
|
@ -8,8 +11,8 @@ import { readdirSync, readFileSync } from 'fs';
|
|||
import { loadWorkflowTestCasesWithFiles } from '../data/workflows';
|
||||
import { WorkflowTestCaseSchema } from '../data/workflows/schema';
|
||||
|
||||
const mockedReaddir = jest.mocked(readdirSync);
|
||||
const mockedReadFile = jest.mocked(readFileSync);
|
||||
const mockedReaddir = vi.mocked(readdirSync);
|
||||
const mockedReadFile = vi.mocked(readFileSync);
|
||||
|
||||
const validFixture = () => ({
|
||||
conversation: [{ role: 'user' as const, text: 'Build a thing' }],
|
||||
|
|
@ -26,7 +29,7 @@ const validFixture = () => ({
|
|||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockedReaddir.mockReturnValue(['demo.json'] as unknown as ReturnType<typeof readdirSync>);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
jest.mock('fs', () => ({
|
||||
readdirSync: jest.fn(),
|
||||
readFileSync: jest.fn(),
|
||||
/* eslint-disable import-x/order */
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
readdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
|
|
@ -8,8 +11,8 @@ import { jsonParse } from 'n8n-workflow';
|
|||
|
||||
import { loadWorkflowTestCasesWithFiles } from '../data/workflows';
|
||||
|
||||
const mockedReaddir = jest.mocked(readdirSync);
|
||||
const mockedReadFile = jest.mocked(readFileSync);
|
||||
const mockedReaddir = vi.mocked(readdirSync);
|
||||
const mockedReadFile = vi.mocked(readFileSync);
|
||||
|
||||
const FAKE_FILES = [
|
||||
'contact-form-automation.json',
|
||||
|
|
@ -37,7 +40,7 @@ const STUB_TEST_CASE = JSON.stringify({
|
|||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockedReaddir.mockReturnValue(FAKE_FILES as unknown as ReturnType<typeof readdirSync>);
|
||||
mockedReadFile.mockReturnValue(STUB_TEST_CASE);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
jest.mock('../data/workflows', () => ({
|
||||
loadWorkflowTestCasesWithFiles: jest.fn(),
|
||||
/* eslint-disable import-x/order */
|
||||
import { vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
vi.mock('../data/workflows', () => ({
|
||||
loadWorkflowTestCasesWithFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { Client } from 'langsmith';
|
||||
|
|
@ -9,7 +13,7 @@ import { loadWorkflowTestCasesWithFiles } from '../data/workflows';
|
|||
import type { EvalLogger } from '../harness/logger';
|
||||
import { syncDataset } from '../langsmith/dataset-sync';
|
||||
|
||||
const mockedLoad = jest.mocked(loadWorkflowTestCasesWithFiles);
|
||||
const mockedLoad = vi.mocked(loadWorkflowTestCasesWithFiles);
|
||||
|
||||
function scenarioFixture(testCaseFile: string, scenarioName: string) {
|
||||
return {
|
||||
|
|
@ -61,13 +65,19 @@ type UpsertArg = Array<{ id: string; inputs: Record<string, unknown> }>;
|
|||
|
||||
function buildClient(existing: Example[] = []): {
|
||||
client: Client;
|
||||
createExamples: jest.Mock<Promise<void>, [UpsertArg]>;
|
||||
updateExamples: jest.Mock<Promise<void>, [UpsertArg]>;
|
||||
deleteExamples: jest.Mock<Promise<void>, [string[]]>;
|
||||
createExamples: Mock<(...args: [UpsertArg]) => Promise<void>>;
|
||||
updateExamples: Mock<(...args: [UpsertArg]) => Promise<void>>;
|
||||
deleteExamples: Mock<(...args: [string[]]) => Promise<void>>;
|
||||
} {
|
||||
const createExamples = jest.fn<Promise<void>, [UpsertArg]>().mockResolvedValue(undefined);
|
||||
const updateExamples = jest.fn<Promise<void>, [UpsertArg]>().mockResolvedValue(undefined);
|
||||
const deleteExamples = jest.fn<Promise<void>, [string[]]>().mockResolvedValue(undefined);
|
||||
const createExamples = vi
|
||||
.fn<(...args: [UpsertArg]) => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const updateExamples = vi
|
||||
.fn<(...args: [UpsertArg]) => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const deleteExamples = vi
|
||||
.fn<(...args: [string[]]) => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
async function* listExamples() {
|
||||
await Promise.resolve();
|
||||
|
|
@ -75,10 +85,10 @@ function buildClient(existing: Example[] = []): {
|
|||
}
|
||||
|
||||
const client = {
|
||||
hasDataset: jest.fn().mockResolvedValue(true),
|
||||
readDataset: jest.fn().mockResolvedValue({ id: 'dataset-1' }),
|
||||
createDataset: jest.fn(),
|
||||
listExamples: jest.fn().mockImplementation(listExamples),
|
||||
hasDataset: vi.fn().mockResolvedValue(true),
|
||||
readDataset: vi.fn().mockResolvedValue({ id: 'dataset-1' }),
|
||||
createDataset: vi.fn(),
|
||||
listExamples: vi.fn().mockImplementation(listExamples),
|
||||
createExamples,
|
||||
updateExamples,
|
||||
deleteExamples,
|
||||
|
|
@ -88,17 +98,17 @@ function buildClient(existing: Example[] = []): {
|
|||
}
|
||||
|
||||
const logger: EvalLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
} as unknown as EvalLogger;
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
describe('syncDataset', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates new examples with random UUIDs when they are not already in the dataset', async () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import { runWorkflowTestCase } from '../harness/runner';
|
||||
|
|
@ -28,16 +31,16 @@ const silentLogger: EvalLogger = {
|
|||
isVerbose: false,
|
||||
};
|
||||
|
||||
function makeClient(overrides: Partial<Record<keyof N8nClient, jest.Mock>> = {}): {
|
||||
function makeClient(overrides: Partial<Record<keyof N8nClient, Mock>> = {}): {
|
||||
client: N8nClient;
|
||||
mocks: Record<string, jest.Mock>;
|
||||
mocks: Record<string, Mock>;
|
||||
} {
|
||||
const mocks: Record<string, jest.Mock> = {
|
||||
getWorkflow: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
deleteWorkflow: jest.fn().mockResolvedValue(undefined),
|
||||
deleteDataTable: jest.fn().mockResolvedValue(undefined),
|
||||
listDataTables: jest.fn().mockResolvedValue([]),
|
||||
const mocks: Record<string, Mock> = {
|
||||
getWorkflow: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
deleteWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
deleteDataTable: vi.fn().mockResolvedValue(undefined),
|
||||
listDataTables: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
};
|
||||
return { client: mocks as unknown as N8nClient, mocks };
|
||||
|
|
@ -64,7 +67,7 @@ describe('runWorkflowTestCase with prebuiltWorkflowId', () => {
|
|||
} as unknown as WorkflowResponse;
|
||||
|
||||
const { client, mocks } = makeClient({
|
||||
getWorkflow: jest.fn().mockResolvedValue(fakeWorkflow),
|
||||
getWorkflow: vi.fn().mockResolvedValue(fakeWorkflow),
|
||||
});
|
||||
|
||||
const result = await runWorkflowTestCase({
|
||||
|
|
@ -96,7 +99,7 @@ describe('runWorkflowTestCase with prebuiltWorkflowId', () => {
|
|||
|
||||
it('reports build failure with the workflow ID when fetch fails', async () => {
|
||||
const { client, mocks } = makeClient({
|
||||
getWorkflow: jest.fn().mockRejectedValue(new Error('HTTP 404')),
|
||||
getWorkflow: vi.fn().mockRejectedValue(new Error('HTTP 404')),
|
||||
});
|
||||
|
||||
const result = await runWorkflowTestCase({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"lib": ["es2023"],
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"types": ["node", "jest"]
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,38 @@
|
|||
/* eslint-disable import-x/order */
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
const mockAgentInstances: Array<{
|
||||
model: jest.Mock;
|
||||
instructions: jest.Mock;
|
||||
tool: jest.Mock;
|
||||
deferredTool: jest.Mock;
|
||||
skills: jest.Mock;
|
||||
checkpoint: jest.Mock;
|
||||
memory: jest.Mock;
|
||||
telemetry: jest.Mock;
|
||||
workspace: jest.Mock;
|
||||
model: Mock;
|
||||
instructions: Mock;
|
||||
tool: Mock;
|
||||
deferredTool: Mock;
|
||||
skills: Mock;
|
||||
checkpoint: Mock;
|
||||
memory: Mock;
|
||||
telemetry: Mock;
|
||||
workspace: Mock;
|
||||
}> = [];
|
||||
|
||||
const mockMemoryBuilder = {
|
||||
storage: jest.fn(),
|
||||
observationalMemory: jest.fn(),
|
||||
build: jest.fn(),
|
||||
storage: vi.fn(),
|
||||
observationalMemory: vi.fn(),
|
||||
build: vi.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@n8n/agents', () => ({
|
||||
Agent: jest.fn().mockImplementation(function Agent(this: (typeof mockAgentInstances)[number]) {
|
||||
this.model = jest.fn().mockReturnThis();
|
||||
this.instructions = jest.fn().mockReturnThis();
|
||||
this.tool = jest.fn().mockReturnThis();
|
||||
this.deferredTool = jest.fn().mockReturnThis();
|
||||
this.skills = jest.fn().mockReturnThis();
|
||||
this.checkpoint = jest.fn().mockReturnThis();
|
||||
this.memory = jest.fn().mockReturnThis();
|
||||
this.telemetry = jest.fn().mockReturnThis();
|
||||
this.workspace = jest.fn().mockReturnThis();
|
||||
vi.mock('@n8n/agents', () => ({
|
||||
Agent: vi.fn().mockImplementation(function Agent(this: (typeof mockAgentInstances)[number]) {
|
||||
this.model = vi.fn().mockReturnThis();
|
||||
this.instructions = vi.fn().mockReturnThis();
|
||||
this.tool = vi.fn().mockReturnThis();
|
||||
this.deferredTool = vi.fn().mockReturnThis();
|
||||
this.skills = vi.fn().mockReturnThis();
|
||||
this.checkpoint = vi.fn().mockReturnThis();
|
||||
this.memory = vi.fn().mockReturnThis();
|
||||
this.telemetry = vi.fn().mockReturnThis();
|
||||
this.workspace = vi.fn().mockReturnThis();
|
||||
mockAgentInstances.push(this);
|
||||
}),
|
||||
Memory: jest.fn().mockImplementation(function Memory() {
|
||||
Memory: vi.fn().mockImplementation(function Memory() {
|
||||
return mockMemoryBuilder;
|
||||
}),
|
||||
}));
|
||||
|
|
@ -37,12 +40,12 @@ jest.mock('@n8n/agents', () => ({
|
|||
const mockBuiltTool = (name: string, marker?: string) => ({
|
||||
name,
|
||||
description: name,
|
||||
handler: jest.fn(),
|
||||
handler: vi.fn(),
|
||||
marker,
|
||||
});
|
||||
|
||||
jest.mock('../../tools', () => ({
|
||||
createAllTools: jest.fn(
|
||||
vi.mock('../../tools', () => ({
|
||||
createAllTools: vi.fn(
|
||||
(context: { runLabel?: string }) =>
|
||||
new Map([
|
||||
['workflows', mockBuiltTool(`workflows-${context.runLabel ?? 'unknown'}`)],
|
||||
|
|
@ -51,7 +54,7 @@ jest.mock('../../tools', () => ({
|
|||
['nodes', mockBuiltTool(`nodes-${context.runLabel ?? 'unknown'}`)],
|
||||
]),
|
||||
),
|
||||
createOrchestratorDomainTools: jest.fn(
|
||||
createOrchestratorDomainTools: vi.fn(
|
||||
(context: { runLabel?: string }) =>
|
||||
new Map([
|
||||
['workflows', mockBuiltTool(`workflows-${context.runLabel ?? 'unknown'}`)],
|
||||
|
|
@ -62,7 +65,7 @@ jest.mock('../../tools', () => ({
|
|||
['build-workflow', mockBuiltTool(`build-workflow-${context.runLabel ?? 'unknown'}`)],
|
||||
]),
|
||||
),
|
||||
createOrchestrationTools: jest.fn(
|
||||
createOrchestrationTools: vi.fn(
|
||||
(context: { runId: string }) =>
|
||||
new Map([
|
||||
['plan', mockBuiltTool(`plan-${context.runId}`)],
|
||||
|
|
@ -73,43 +76,36 @@ jest.mock('../../tools', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../tools/filesystem/create-tools-from-mcp-server', () => ({
|
||||
createToolsFromLocalMcpServer: jest.fn().mockReturnValue(new Map()),
|
||||
vi.mock('../../tools/filesystem/create-tools-from-mcp-server', () => ({
|
||||
createToolsFromLocalMcpServer: vi.fn().mockReturnValue(new Map()),
|
||||
}));
|
||||
|
||||
jest.mock('../../tracing/langsmith-tracing', () => ({
|
||||
buildAgentTraceInputs: jest.fn().mockReturnValue({}),
|
||||
mergeTraceRunInputs: jest.fn(),
|
||||
vi.mock('../../tracing/langsmith-tracing', () => ({
|
||||
buildAgentTraceInputs: vi.fn().mockReturnValue({}),
|
||||
mergeTraceRunInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../system-prompt', () => ({
|
||||
getSystemPrompt: jest.fn().mockReturnValue('system prompt'),
|
||||
vi.mock('../system-prompt', () => ({
|
||||
getSystemPrompt: vi.fn().mockReturnValue('system prompt'),
|
||||
}));
|
||||
|
||||
const { createInstanceAgent } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../instance-agent') as typeof import('../instance-agent');
|
||||
const { Agent, Memory } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('@n8n/agents') as {
|
||||
Agent: jest.Mock;
|
||||
Memory: jest.Mock;
|
||||
};
|
||||
const { createToolsFromLocalMcpServer } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('../../tools/filesystem/create-tools-from-mcp-server') as {
|
||||
createToolsFromLocalMcpServer: jest.Mock;
|
||||
};
|
||||
const { createOrchestratorDomainTools } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('../../tools') as { createOrchestratorDomainTools: jest.Mock };
|
||||
import { Agent as AgentImport, Memory as MemoryImport } from '@n8n/agents';
|
||||
|
||||
import { createOrchestratorDomainTools as createOrchestratorDomainToolsImport } from '../../tools';
|
||||
import { createToolsFromLocalMcpServer as createToolsFromLocalMcpServerImport } from '../../tools/filesystem/create-tools-from-mcp-server';
|
||||
import { createInstanceAgent } from '../instance-agent';
|
||||
|
||||
const Agent = AgentImport as unknown as Mock;
|
||||
const Memory = MemoryImport as unknown as Mock;
|
||||
const createToolsFromLocalMcpServer = createToolsFromLocalMcpServerImport as unknown as Mock;
|
||||
const createOrchestratorDomainTools = createOrchestratorDomainToolsImport as unknown as Mock;
|
||||
|
||||
function createMcpManagerStub(
|
||||
regularTools: Map<string, ReturnType<typeof mockBuiltTool>> = new Map(),
|
||||
) {
|
||||
return {
|
||||
getRegularTools: jest.fn().mockResolvedValue(regularTools),
|
||||
disconnect: jest.fn().mockResolvedValue(undefined),
|
||||
getRegularTools: vi.fn().mockResolvedValue(regularTools),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -281,8 +277,8 @@ describe('createInstanceAgent', () => {
|
|||
orchestrationContext: {
|
||||
runId: 'trace-test',
|
||||
tracing: {
|
||||
getTelemetry: jest.fn().mockReturnValue(telemetry),
|
||||
wrapTools: jest.fn((tools: unknown) => tools),
|
||||
getTelemetry: vi.fn().mockReturnValue(telemetry),
|
||||
wrapTools: vi.fn((tools: unknown) => tools),
|
||||
},
|
||||
},
|
||||
memoryConfig: {},
|
||||
|
|
@ -314,7 +310,7 @@ describe('createInstanceAgent', () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
loadSkill: jest.fn(),
|
||||
loadSkill: vi.fn(),
|
||||
};
|
||||
|
||||
await createInstanceAgent({
|
||||
|
|
@ -349,7 +345,7 @@ describe('createInstanceAgent', () => {
|
|||
|
||||
const memoryConfig = { storage: { id: 'memory-store' } } as never;
|
||||
const localMcpServer = {
|
||||
getToolsByCategory: jest
|
||||
getToolsByCategory: vi
|
||||
.fn()
|
||||
.mockReturnValue([{ name: 'browser_connect' }, { name: 'browser_navigate' }]),
|
||||
};
|
||||
|
|
@ -378,7 +374,7 @@ describe('createInstanceAgent', () => {
|
|||
it('prefers local gateway tools over external MCP tools when names collide', async () => {
|
||||
const memoryConfig = {} as never;
|
||||
const localMcpServer = {
|
||||
getToolsByCategory: jest.fn().mockReturnValue([]),
|
||||
getToolsByCategory: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
const localTools = new Map([['shared_tool', mockBuiltTool('shared_tool', 'local-shared')]]);
|
||||
const externalTools = new Map([
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -19,14 +19,14 @@ function createSandboxWorkspace(files: Map<string, string>): {
|
|||
const workspace: SandboxWorkspace = {
|
||||
filesystem: {
|
||||
provider: 'local',
|
||||
writeFile: jest.fn(async (path: string, content: string | Buffer) => {
|
||||
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
|
||||
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
|
||||
await Promise.resolve();
|
||||
}),
|
||||
mkdir: jest.fn(async () => await Promise.resolve()),
|
||||
mkdir: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
sandbox: {
|
||||
executeCommand: jest.fn(async (command: string) => {
|
||||
executeCommand: vi.fn(async (command: string) => {
|
||||
const readMatch = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
|
||||
if (readMatch) {
|
||||
const content = files.get(readMatch[1]);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
jest.mock('@n8n/agents', () => ({
|
||||
McpClient: jest.fn().mockImplementation(() => ({
|
||||
listTools: jest.fn().mockResolvedValue([]),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
/* eslint-disable import-x/order */
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
|
||||
vi.mock('@n8n/agents', () => ({
|
||||
McpClient: vi.fn(function () {
|
||||
return {
|
||||
listTools: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../agent/sanitize-mcp-schemas', () => ({
|
||||
sanitizeMcpToolSchemas: jest.fn((tools: unknown) => tools),
|
||||
vi.mock('../../agent/sanitize-mcp-schemas', () => ({
|
||||
sanitizeMcpToolSchemas: vi.fn((tools: unknown) => tools),
|
||||
}));
|
||||
|
||||
import { McpClient } from '@n8n/agents';
|
||||
import { createResultError, createResultOk, UserError } from 'n8n-workflow';
|
||||
|
||||
import { sanitizeMcpToolSchemas } from '../../agent/sanitize-mcp-schemas';
|
||||
import type { SsrfUrlValidator } from '../mcp-client-manager';
|
||||
import { McpClientManager } from '../mcp-client-manager';
|
||||
|
||||
const { McpClient: mockedMcpClient } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('@n8n/agents') as { McpClient: jest.Mock };
|
||||
const { sanitizeMcpToolSchemas: mockedSanitizeMcpToolSchemas } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('../../agent/sanitize-mcp-schemas') as {
|
||||
sanitizeMcpToolSchemas: jest.Mock;
|
||||
};
|
||||
const mockedMcpClient = McpClient as unknown as Mock;
|
||||
const mockedSanitizeMcpToolSchemas = sanitizeMcpToolSchemas as unknown as Mock;
|
||||
|
||||
interface LoggerMock {
|
||||
warn: jest.Mock;
|
||||
warn: Mock;
|
||||
}
|
||||
|
||||
interface SanitizeOptions {
|
||||
|
|
@ -41,15 +42,15 @@ interface SanitizeOptions {
|
|||
}) => void;
|
||||
}
|
||||
|
||||
function createValidatorMock(): jest.Mocked<SsrfUrlValidator> {
|
||||
function createValidatorMock(): Mocked<SsrfUrlValidator> {
|
||||
return {
|
||||
validateUrl: jest.fn().mockResolvedValue(createResultOk(undefined)),
|
||||
} as jest.Mocked<SsrfUrlValidator>;
|
||||
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
|
||||
} as Mocked<SsrfUrlValidator>;
|
||||
}
|
||||
|
||||
describe('McpClientManager', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('protocol whitelist (always-on)', () => {
|
||||
|
|
@ -108,7 +109,7 @@ describe('McpClientManager', () => {
|
|||
|
||||
describe('server and schema filtering', () => {
|
||||
it('skips external MCP servers with unsafe names', async () => {
|
||||
const logger: LoggerMock = { warn: jest.fn() };
|
||||
const logger: LoggerMock = { warn: vi.fn() };
|
||||
const manager = new McpClientManager();
|
||||
|
||||
await manager.getRegularTools(
|
||||
|
|
@ -138,7 +139,7 @@ describe('McpClientManager', () => {
|
|||
});
|
||||
|
||||
it('logs tools skipped during schema sanitization', async () => {
|
||||
const logger: LoggerMock = { warn: jest.fn() };
|
||||
const logger: LoggerMock = { warn: vi.fn() };
|
||||
mockedSanitizeMcpToolSchemas.mockImplementationOnce(
|
||||
(_tools: unknown, options?: SanitizeOptions) => {
|
||||
options?.onError?.({
|
||||
|
|
@ -227,7 +228,7 @@ describe('McpClientManager', () => {
|
|||
expect(mockedMcpClient).toHaveBeenCalledTimes(1);
|
||||
|
||||
const disconnectMocks = mockedMcpClient.mock.results.map(
|
||||
(r) => (r.value as { close: jest.Mock }).close,
|
||||
(r) => (r.value as { close: Mock }).close,
|
||||
);
|
||||
|
||||
await manager.disconnect();
|
||||
|
|
@ -306,10 +307,12 @@ describe('McpClientManager', () => {
|
|||
const manager = new McpClientManager();
|
||||
const configs = [{ name: 'a', url: 'https://a.example.com/' }];
|
||||
|
||||
mockedMcpClient.mockImplementationOnce(() => ({
|
||||
listTools: jest.fn().mockRejectedValue(new Error('boom')),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
mockedMcpClient.mockImplementationOnce(function () {
|
||||
return {
|
||||
listTools: vi.fn().mockRejectedValue(new Error('boom')),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
await expect(manager.getRegularTools(configs)).rejects.toThrow('boom');
|
||||
// In-flight entry must be cleared so a retry actually re-attempts.
|
||||
|
|
@ -334,10 +337,12 @@ describe('McpClientManager', () => {
|
|||
const configs = [{ name: 'a', url: 'https://a.example.com/' }];
|
||||
|
||||
const deferred = deferListTools();
|
||||
mockedMcpClient.mockImplementationOnce(() => ({
|
||||
listTools: jest.fn().mockReturnValue(deferred.promise),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
mockedMcpClient.mockImplementationOnce(function () {
|
||||
return {
|
||||
listTools: vi.fn().mockReturnValue(deferred.promise),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const stranded = manager.getRegularTools(configs);
|
||||
// Yield so connectAndListTools registers the client before we tear down.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { extractDocxText } from '../docx-parser';
|
||||
import { MAX_DECODED_SIZE_BYTES } from '../structured-file-parser';
|
||||
|
||||
const mockExtractRawText = jest.fn<Promise<{ value: string; messages: unknown[] }>, [unknown]>();
|
||||
const { mockExtractRawText } = vi.hoisted(() => ({
|
||||
mockExtractRawText: vi.fn<(arg: unknown) => Promise<{ value: string; messages: unknown[] }>>(),
|
||||
}));
|
||||
|
||||
jest.mock('mammoth', () => ({
|
||||
vi.mock('mammoth', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
extractRawText: async (input: { buffer: Buffer }) => await mockExtractRawText(input),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { extractPdfText } from '../pdf-parser';
|
||||
import { MAX_DECODED_SIZE_BYTES } from '../structured-file-parser';
|
||||
|
||||
const mockGetText = jest.fn<Promise<{ text: string; total: number }>, []>();
|
||||
const mockDestroy = jest.fn<Promise<void>, []>();
|
||||
|
||||
jest.mock('pdf-parse', () => ({
|
||||
__esModule: true,
|
||||
PDFParse: jest.fn().mockImplementation(() => ({
|
||||
getText: mockGetText,
|
||||
destroy: mockDestroy,
|
||||
})),
|
||||
const { mockGetText, mockDestroy } = vi.hoisted(() => ({
|
||||
mockGetText: vi.fn<() => Promise<{ text: string; total: number }>>(),
|
||||
mockDestroy: vi.fn<() => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock('pdf-parse', () => {
|
||||
class PDFParse {
|
||||
getText = mockGetText;
|
||||
destroy = mockDestroy;
|
||||
}
|
||||
return { PDFParse };
|
||||
});
|
||||
|
||||
function toBase64(content: string | Buffer): string {
|
||||
const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
|
||||
return buf.toString('base64');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import type { Mocked } from 'vitest';
|
||||
|
||||
import type { PlannedTaskStorage } from '../../storage/planned-task-storage';
|
||||
import type { PlannedTask, PlannedTaskGraph, PlannedTaskRecord } from '../../types';
|
||||
import { PlannedTaskCoordinator } from '../planned-task-service';
|
||||
|
||||
function makeStorage(): jest.Mocked<PlannedTaskStorage> {
|
||||
function makeStorage(): Mocked<PlannedTaskStorage> {
|
||||
return {
|
||||
get: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
} as unknown as jest.Mocked<PlannedTaskStorage>;
|
||||
get: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as Mocked<PlannedTaskStorage>;
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<PlannedTask> = {}): PlannedTask {
|
||||
|
|
@ -44,11 +46,11 @@ function makeTaskRecord(overrides: Partial<PlannedTaskRecord> = {}): PlannedTask
|
|||
}
|
||||
|
||||
describe('PlannedTaskCoordinator', () => {
|
||||
let storage: jest.Mocked<PlannedTaskStorage>;
|
||||
let storage: Mocked<PlannedTaskStorage>;
|
||||
let coordinator: PlannedTaskCoordinator;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
storage = makeStorage();
|
||||
coordinator = new PlannedTaskCoordinator(storage);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ function makeSpawnOptions(
|
|||
runId: 'run-1',
|
||||
role: 'builder',
|
||||
agentId: 'agent-1',
|
||||
run: jest.fn().mockResolvedValue('done'),
|
||||
run: vi.fn().mockResolvedValue('done'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -39,8 +39,8 @@ describe('BackgroundTaskManager', () => {
|
|||
});
|
||||
|
||||
it('fails and settles idle running tasks', async () => {
|
||||
const onFailed = jest.fn((_task: ManagedBackgroundTask) => undefined);
|
||||
const onSettled = jest.fn((_task: ManagedBackgroundTask) => undefined);
|
||||
const onFailed = vi.fn((_task: ManagedBackgroundTask) => undefined);
|
||||
const onSettled = vi.fn((_task: ManagedBackgroundTask) => undefined);
|
||||
let signal: AbortSignal | undefined;
|
||||
|
||||
manager.spawn(
|
||||
|
|
@ -132,8 +132,8 @@ describe('BackgroundTaskManager', () => {
|
|||
});
|
||||
|
||||
it('rejects spawn when concurrent limit is reached', () => {
|
||||
const onLimitReached = jest.fn();
|
||||
const createTraceContext = jest.fn();
|
||||
const onLimitReached = vi.fn();
|
||||
const createTraceContext = vi.fn();
|
||||
|
||||
manager.spawn(
|
||||
makeSpawnOptions({ taskId: 't1', run: async () => await new Promise(() => {}) }),
|
||||
|
|
@ -156,8 +156,8 @@ describe('BackgroundTaskManager', () => {
|
|||
|
||||
it('creates lazy trace context only after a task is accepted', async () => {
|
||||
const traceContext = { projectName: 'instance-ai' } as never;
|
||||
const createTraceContext = jest.fn().mockResolvedValue(traceContext);
|
||||
const run = jest.fn().mockResolvedValue('done');
|
||||
const createTraceContext = vi.fn().mockResolvedValue(traceContext);
|
||||
const run = vi.fn().mockResolvedValue('done');
|
||||
|
||||
manager.spawn(makeSpawnOptions({ createTraceContext, run }));
|
||||
await flushPromises();
|
||||
|
|
@ -172,8 +172,8 @@ describe('BackgroundTaskManager', () => {
|
|||
});
|
||||
|
||||
it('calls onCompleted and onSettled when run resolves with string', async () => {
|
||||
const onCompleted = jest.fn();
|
||||
const onSettled = jest.fn();
|
||||
const onCompleted = vi.fn();
|
||||
const onSettled = vi.fn();
|
||||
const { promise, resolve } = createDeferred<string>();
|
||||
|
||||
manager.spawn(
|
||||
|
|
@ -194,7 +194,7 @@ describe('BackgroundTaskManager', () => {
|
|||
});
|
||||
|
||||
it('calls onCompleted with structured result', async () => {
|
||||
const onCompleted = jest.fn();
|
||||
const onCompleted = vi.fn();
|
||||
const { promise, resolve } = createDeferred<string | BackgroundTaskResult>();
|
||||
|
||||
manager.spawn(
|
||||
|
|
@ -217,8 +217,8 @@ describe('BackgroundTaskManager', () => {
|
|||
});
|
||||
|
||||
it('calls onFailed and onSettled when run rejects', async () => {
|
||||
const onFailed = jest.fn();
|
||||
const onSettled = jest.fn();
|
||||
const onFailed = vi.fn();
|
||||
const onSettled = vi.fn();
|
||||
const { promise, reject } = createDeferred<string | BackgroundTaskResult>();
|
||||
|
||||
manager.spawn(
|
||||
|
|
@ -239,7 +239,7 @@ describe('BackgroundTaskManager', () => {
|
|||
});
|
||||
|
||||
it('does not call onFailed when aborted', async () => {
|
||||
const onFailed = jest.fn();
|
||||
const onFailed = vi.fn();
|
||||
const { promise, reject } = createDeferred<string | BackgroundTaskResult>();
|
||||
|
||||
manager.spawn(
|
||||
|
|
@ -257,7 +257,7 @@ describe('BackgroundTaskManager', () => {
|
|||
});
|
||||
|
||||
it('does not call onSettled when aborted', async () => {
|
||||
const onSettled = jest.fn();
|
||||
const onSettled = vi.fn();
|
||||
const { promise, reject } = createDeferred<string | BackgroundTaskResult>();
|
||||
|
||||
manager.spawn(
|
||||
|
|
@ -298,7 +298,7 @@ describe('BackgroundTaskManager', () => {
|
|||
);
|
||||
expect(first.status).toBe('started');
|
||||
|
||||
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const run = vi.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'second',
|
||||
|
|
@ -323,7 +323,7 @@ describe('BackgroundTaskManager', () => {
|
|||
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-trace' },
|
||||
}),
|
||||
);
|
||||
const createTraceContext = jest.fn();
|
||||
const createTraceContext = vi.fn();
|
||||
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
|
|
@ -369,7 +369,7 @@ describe('BackgroundTaskManager', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const run = vi.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'second',
|
||||
|
|
@ -400,7 +400,7 @@ describe('BackgroundTaskManager', () => {
|
|||
);
|
||||
expect(first.status).toBe('started');
|
||||
|
||||
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const run = vi.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'task-B',
|
||||
|
|
@ -447,7 +447,7 @@ describe('BackgroundTaskManager', () => {
|
|||
manager.spawn({ ...filler, taskId: 't2' });
|
||||
manager.spawn({ ...filler, taskId: 't3' });
|
||||
|
||||
const onLimitReached = jest.fn();
|
||||
const onLimitReached = vi.fn();
|
||||
const result = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 't4',
|
||||
|
|
|
|||
|
|
@ -3,17 +3,17 @@ import { executeResumableStream, normalizeStreamSource } from '../resumable-stre
|
|||
|
||||
function createEventBus() {
|
||||
return {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getEventsAfter: jest.fn(),
|
||||
getNextEventId: jest.fn(),
|
||||
getEventsForRun: jest.fn().mockReturnValue([]),
|
||||
getEventsForRuns: jest.fn().mockReturnValue([]),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
getEventsAfter: vi.fn(),
|
||||
getNextEventId: vi.fn(),
|
||||
getEventsForRun: vi.fn().mockReturnValue([]),
|
||||
getEventsForRuns: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
|
||||
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
}
|
||||
|
||||
async function* fromChunks(chunks: unknown[]) {
|
||||
|
|
@ -92,7 +92,7 @@ describe('normalizeStreamSource', () => {
|
|||
},
|
||||
},
|
||||
]),
|
||||
getState: jest.fn(),
|
||||
getState: vi.fn(),
|
||||
});
|
||||
|
||||
expect(source.runId).toBe('agent-run-1');
|
||||
|
|
@ -178,7 +178,7 @@ describe('executeResumableStream', () => {
|
|||
});
|
||||
|
||||
it('reports liveness activity for each consumed chunk', async () => {
|
||||
const onActivity = jest.fn();
|
||||
const onActivity = vi.fn();
|
||||
|
||||
await executeResumableStream({
|
||||
agent: {},
|
||||
|
|
@ -234,9 +234,7 @@ describe('executeResumableStream', () => {
|
|||
control: { mode: 'manual' },
|
||||
});
|
||||
|
||||
const publishedEvents = eventBus.publish.mock.calls.map(
|
||||
([, event]: [string, PublishedEvent]) => event,
|
||||
);
|
||||
const publishedEvents = eventBus.publish.mock.calls.map(([, event]) => event as PublishedEvent);
|
||||
const firstText = publishedEvents.find((event) => event.payload?.text === 'First');
|
||||
const toolCall = publishedEvents.find((event) => event.type === 'tool-call');
|
||||
const firstStepContinuation = publishedEvents.find((event) => event.payload?.text === ' step');
|
||||
|
|
@ -250,11 +248,11 @@ describe('executeResumableStream', () => {
|
|||
|
||||
it('auto-resumes suspended streams and passes drained corrections to resume data', async () => {
|
||||
const eventBus = createEventBus();
|
||||
const resume = jest.fn().mockResolvedValue({
|
||||
const resume = vi.fn().mockResolvedValue({
|
||||
runId: 'agent-run-2',
|
||||
stream: readableFromChunks([textChunk('Done.')]),
|
||||
});
|
||||
const waitForConfirmation = jest.fn().mockResolvedValue({ approved: true });
|
||||
const waitForConfirmation = vi.fn().mockResolvedValue({ approved: true });
|
||||
let hasDrainedCorrection = false;
|
||||
|
||||
const result = await executeResumableStream({
|
||||
|
|
@ -317,7 +315,7 @@ describe('executeResumableStream', () => {
|
|||
});
|
||||
|
||||
it('passes resume options from the control hook', async () => {
|
||||
const resume = jest.fn().mockResolvedValue({
|
||||
const resume = vi.fn().mockResolvedValue({
|
||||
runId: 'agent-run-2',
|
||||
stream: readableFromChunks([textChunk('Done.')]),
|
||||
});
|
||||
|
|
@ -365,11 +363,11 @@ describe('executeResumableStream', () => {
|
|||
const finishGate = createDeferred<undefined>();
|
||||
const approval = createDeferred<Record<string, unknown>>();
|
||||
const waitStarted = createDeferred<undefined>();
|
||||
const resume = jest.fn().mockResolvedValue({
|
||||
const resume = vi.fn().mockResolvedValue({
|
||||
runId: 'agent-run-2',
|
||||
stream: readableFromChunks([textChunk('Done.')]),
|
||||
});
|
||||
const waitForConfirmation = jest.fn().mockImplementation(async () => {
|
||||
const waitForConfirmation = vi.fn().mockImplementation(async () => {
|
||||
waitStarted.resolve(undefined);
|
||||
return await approval.promise;
|
||||
});
|
||||
|
|
@ -429,12 +427,12 @@ describe('executeResumableStream', () => {
|
|||
|
||||
it('surfaces only the first actionable suspension in a drain', async () => {
|
||||
const eventBus = createEventBus();
|
||||
const resume = jest.fn().mockResolvedValue({
|
||||
const resume = vi.fn().mockResolvedValue({
|
||||
runId: 'agent-run-2',
|
||||
stream: readableFromChunks([textChunk('Done.')]),
|
||||
});
|
||||
const waitForConfirmation = jest.fn().mockResolvedValue({ approved: true });
|
||||
const onSuspension = jest.fn((_: SuspensionInfo) => undefined);
|
||||
const waitForConfirmation = vi.fn().mockResolvedValue({ approved: true });
|
||||
const onSuspension = vi.fn((_: SuspensionInfo) => undefined);
|
||||
|
||||
await executeResumableStream({
|
||||
agent: { resume },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3,29 +3,31 @@ import type * as ResumableStreamExecutor from '../resumable-stream-executor';
|
|||
import { executeResumableStream } from '../resumable-stream-executor';
|
||||
import { streamAgentRun } from '../stream-runner';
|
||||
|
||||
jest.mock('../resumable-stream-executor', () => {
|
||||
const actual = jest.requireActual<typeof ResumableStreamExecutor>('../resumable-stream-executor');
|
||||
vi.mock('../resumable-stream-executor', async () => {
|
||||
const actual = await vi.importActual<typeof ResumableStreamExecutor>(
|
||||
'../resumable-stream-executor',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
executeResumableStream: jest.fn(),
|
||||
executeResumableStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const emptyWorkSummary: WorkSummary = { toolCalls: [], totalToolCalls: 0, totalToolErrors: 0 };
|
||||
|
||||
function createLogger() {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
|
||||
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
}
|
||||
|
||||
function createEventBus() {
|
||||
return {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getEventsAfter: jest.fn(),
|
||||
getNextEventId: jest.fn(),
|
||||
getEventsForRun: jest.fn().mockReturnValue([]),
|
||||
getEventsForRuns: jest.fn().mockReturnValue([]),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
getEventsAfter: vi.fn(),
|
||||
getNextEventId: vi.fn(),
|
||||
getEventsForRun: vi.fn().mockReturnValue([]),
|
||||
getEventsForRuns: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -51,14 +53,14 @@ async function collectAsyncIterable(stream: AsyncIterable<unknown>) {
|
|||
|
||||
describe('streamAgentRun', () => {
|
||||
it('returns errored status when agent stream contains an error chunk', async () => {
|
||||
jest.mocked(executeResumableStream).mockResolvedValue({
|
||||
vi.mocked(executeResumableStream).mockResolvedValue({
|
||||
status: 'errored',
|
||||
agentRunId: 'agent-run-1',
|
||||
workSummary: emptyWorkSummary,
|
||||
});
|
||||
const eventBus = createEventBus();
|
||||
const agent = {
|
||||
stream: jest.fn().mockResolvedValue({
|
||||
stream: vi.fn().mockResolvedValue({
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([
|
||||
{ type: 'text-delta', delta: 'Hello' },
|
||||
|
|
@ -87,14 +89,14 @@ describe('streamAgentRun', () => {
|
|||
});
|
||||
|
||||
it('returns completed status for successful streams', async () => {
|
||||
jest.mocked(executeResumableStream).mockResolvedValue({
|
||||
vi.mocked(executeResumableStream).mockResolvedValue({
|
||||
status: 'completed',
|
||||
agentRunId: 'agent-run-1',
|
||||
workSummary: emptyWorkSummary,
|
||||
});
|
||||
const eventBus = createEventBus();
|
||||
const agent = {
|
||||
stream: jest.fn().mockResolvedValue({
|
||||
stream: vi.fn().mockResolvedValue({
|
||||
runId: 'agent-run-1',
|
||||
fullStream: fromChunks([{ type: 'text-delta', delta: 'All good' }]),
|
||||
}),
|
||||
|
|
@ -119,9 +121,9 @@ describe('streamAgentRun', () => {
|
|||
});
|
||||
|
||||
it('passes through the buffered manual confirmation event', async () => {
|
||||
const mockedExecuteResumableStream = jest.mocked(executeResumableStream);
|
||||
const mockedExecuteResumableStream = vi.mocked(executeResumableStream);
|
||||
const agent = {
|
||||
stream: jest.fn().mockResolvedValue({
|
||||
stream: vi.fn().mockResolvedValue({
|
||||
runId: 'agent-run-1',
|
||||
fullStream: emptyStream(),
|
||||
}),
|
||||
|
|
@ -181,7 +183,7 @@ describe('streamAgentRun', () => {
|
|||
});
|
||||
|
||||
it('passes an already-normalized native stream source through to the resumable executor', async () => {
|
||||
const mockedExecuteResumableStream = jest.mocked(executeResumableStream);
|
||||
const mockedExecuteResumableStream = vi.mocked(executeResumableStream);
|
||||
const streamResult = {
|
||||
runId: 'agent-run-2',
|
||||
fullStream: emptyStream(),
|
||||
|
|
@ -191,7 +193,7 @@ describe('streamAgentRun', () => {
|
|||
usage: Promise.resolve({ inputTokens: 1, outputTokens: 2, totalTokens: 3 }),
|
||||
};
|
||||
const agent = {
|
||||
stream: jest.fn().mockResolvedValue(streamResult),
|
||||
stream: vi.fn().mockResolvedValue(streamResult),
|
||||
};
|
||||
const eventBus = createEventBus();
|
||||
|
||||
|
|
@ -224,7 +226,7 @@ describe('streamAgentRun', () => {
|
|||
});
|
||||
|
||||
it('normalizes native agent readable streams for the resumable executor', async () => {
|
||||
const mockedExecuteResumableStream = jest.mocked(executeResumableStream);
|
||||
const mockedExecuteResumableStream = vi.mocked(executeResumableStream);
|
||||
mockedExecuteResumableStream.mockClear();
|
||||
const nativeChunk = { type: 'text-delta', delta: 'All good' };
|
||||
const readable = new ReadableStream<unknown>({
|
||||
|
|
@ -234,10 +236,10 @@ describe('streamAgentRun', () => {
|
|||
},
|
||||
});
|
||||
const agent = {
|
||||
stream: jest.fn().mockResolvedValue({
|
||||
stream: vi.fn().mockResolvedValue({
|
||||
runId: 'agent-run-1',
|
||||
stream: readable,
|
||||
getState: jest.fn(),
|
||||
getState: vi.fn(),
|
||||
}),
|
||||
};
|
||||
const eventBus = createEventBus();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type WorkspaceSandbox,
|
||||
} from '@n8n/agents';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import {
|
||||
N8N_SKILLS_DIR_ENV,
|
||||
|
|
@ -23,18 +24,19 @@ import { loadInstanceAiRuntimeSkillSource } from '../runtime-skills';
|
|||
|
||||
function createMockWorkspace() {
|
||||
const writes = new Map<string, string>();
|
||||
const writeFile = jest.fn(async (path: string, content: string | Buffer) => {
|
||||
const writeFile = vi.fn(async (path: string, content: string | Buffer) => {
|
||||
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const readFile = jest.fn(async (path: string) => {
|
||||
const readFile = vi.fn(async (path: string) => {
|
||||
const content = writes.get(path);
|
||||
if (content === undefined) throw new Error(`ENOENT: ${path}`);
|
||||
return await Promise.resolve(content);
|
||||
});
|
||||
const executeCommand = jest.fn<
|
||||
ReturnType<NonNullable<WorkspaceSandbox['executeCommand']>>,
|
||||
Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>
|
||||
const executeCommand = vi.fn<
|
||||
(
|
||||
...args: Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>
|
||||
) => ReturnType<NonNullable<WorkspaceSandbox['executeCommand']>>
|
||||
>(
|
||||
async () =>
|
||||
await Promise.resolve({
|
||||
|
|
@ -374,7 +376,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
|
|||
}),
|
||||
};
|
||||
const { workspace } = createMockWorkspace();
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
|
||||
await materializeRuntimeSkillsIntoWorkspace({
|
||||
source,
|
||||
|
|
@ -383,9 +385,8 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
|
|||
logger,
|
||||
});
|
||||
|
||||
const warnMock = logger.warn as jest.Mock<
|
||||
void,
|
||||
[string, { skill?: unknown; bytes?: unknown; maxBytes?: unknown }?]
|
||||
const warnMock = logger.warn as Mock<
|
||||
(message: string, meta?: { skill?: unknown; bytes?: unknown; maxBytes?: unknown }) => void
|
||||
>;
|
||||
const limitWarnCall = warnMock.mock.calls.find(
|
||||
(call) => call[0] === 'Runtime skill file exceeds load_skill output limit',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { PlannedTaskGraph } from '../../types';
|
||||
import { PlannedTaskStorage } from '../planned-task-storage';
|
||||
import { patchThread, type PatchableThreadMemory } from '../thread-patch';
|
||||
import type * as ThreadPatch from '../thread-patch';
|
||||
|
||||
jest.mock('../thread-patch', () => {
|
||||
const actual =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
jest.requireActual<typeof ThreadPatch>('../thread-patch');
|
||||
vi.mock('../thread-patch', async () => {
|
||||
const actual = await vi.importActual<typeof ThreadPatch>('../thread-patch');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
patchThread: jest.fn(),
|
||||
patchThread: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedPatchThread = jest.mocked(patchThread);
|
||||
type TestMemory = PatchableThreadMemory & { getThread: jest.Mock };
|
||||
const mockedPatchThread = vi.mocked(patchThread);
|
||||
type TestMemory = PatchableThreadMemory & { getThread: Mock };
|
||||
|
||||
function makeMemory(): TestMemory {
|
||||
return {
|
||||
getThread: jest.fn(),
|
||||
getThread: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ describe('PlannedTaskStorage', () => {
|
|||
let storage: PlannedTaskStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
memory = makeMemory();
|
||||
storage = new PlannedTaskStorage(memory);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { IterationEntry } from '../iteration-log';
|
||||
import { ThreadIterationLogStorage } from '../thread-iteration-log-storage';
|
||||
import { patchThread, type PatchableThreadMemory } from '../thread-patch';
|
||||
import type * as ThreadPatch from '../thread-patch';
|
||||
|
||||
jest.mock('../thread-patch', () => {
|
||||
const actual =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
jest.requireActual<typeof ThreadPatch>('../thread-patch');
|
||||
vi.mock('../thread-patch', async () => {
|
||||
const actual = await vi.importActual<typeof ThreadPatch>('../thread-patch');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
patchThread: jest.fn(),
|
||||
patchThread: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedPatchThread = jest.mocked(patchThread);
|
||||
type TestMemory = PatchableThreadMemory & { getThread: jest.Mock };
|
||||
const mockedPatchThread = vi.mocked(patchThread);
|
||||
type TestMemory = PatchableThreadMemory & { getThread: Mock };
|
||||
|
||||
function makeMemory(): TestMemory {
|
||||
return {
|
||||
getThread: jest.fn(),
|
||||
getThread: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ describe('ThreadIterationLogStorage', () => {
|
|||
let storage: ThreadIterationLogStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
memory = makeMemory();
|
||||
storage = new ThreadIterationLogStorage(memory);
|
||||
});
|
||||
|
|
@ -99,7 +99,7 @@ describe('ThreadIterationLogStorage', () => {
|
|||
describe('getForTask', () => {
|
||||
it('returns entries for a specific task key', async () => {
|
||||
const entry = makeEntry();
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
id: 'thread-1',
|
||||
title: 'Test',
|
||||
metadata: {
|
||||
|
|
@ -115,7 +115,7 @@ describe('ThreadIterationLogStorage', () => {
|
|||
});
|
||||
|
||||
it('returns empty array when thread has no log', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
id: 'thread-1',
|
||||
title: 'Test',
|
||||
metadata: {},
|
||||
|
|
@ -129,14 +129,14 @@ describe('ThreadIterationLogStorage', () => {
|
|||
});
|
||||
|
||||
it('returns empty array when thread not found', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue(null);
|
||||
vi.mocked(memory.getThread).mockResolvedValue(null);
|
||||
|
||||
const result = await storage.getForTask('unknown', 'task-key');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when task key does not exist', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
id: 'thread-1',
|
||||
title: 'Test',
|
||||
metadata: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import {
|
||||
getThread,
|
||||
patchThread,
|
||||
|
|
@ -15,23 +17,23 @@ const baseThread: ThreadRecord = {
|
|||
};
|
||||
|
||||
type TestMemory = PatchableThreadMemory & {
|
||||
getThread: jest.Mock;
|
||||
saveThread: jest.Mock;
|
||||
getThread: Mock;
|
||||
saveThread: Mock;
|
||||
};
|
||||
|
||||
function makeMemory(overrides: Partial<TestMemory> = {}): TestMemory {
|
||||
return {
|
||||
...overrides,
|
||||
getThread: overrides.getThread ?? jest.fn().mockResolvedValue({ ...baseThread }),
|
||||
getThread: overrides.getThread ?? vi.fn().mockResolvedValue({ ...baseThread }),
|
||||
saveThread:
|
||||
overrides.saveThread ?? jest.fn().mockImplementation((thread: ThreadRecord) => thread),
|
||||
overrides.saveThread ?? vi.fn().mockImplementation((thread: ThreadRecord) => thread),
|
||||
};
|
||||
}
|
||||
|
||||
describe('getThread', () => {
|
||||
it('uses native getThread when available', async () => {
|
||||
const memory = makeMemory({
|
||||
getThread: jest.fn().mockResolvedValue({ ...baseThread, title: 'Native' }),
|
||||
getThread: vi.fn().mockResolvedValue({ ...baseThread, title: 'Native' }),
|
||||
});
|
||||
|
||||
const result = await getThread(memory, 'thread-1');
|
||||
|
|
@ -50,9 +52,9 @@ describe('getThread', () => {
|
|||
describe('patchThread', () => {
|
||||
describe('when memory has patchThread method', () => {
|
||||
it('calls memory.patchThread directly', async () => {
|
||||
const patchFn = jest.fn().mockResolvedValue({ ...baseThread, title: 'Patched' });
|
||||
const patchFn = vi.fn().mockResolvedValue({ ...baseThread, title: 'Patched' });
|
||||
const memory = makeMemory({ patchThread: patchFn });
|
||||
const update = jest.fn().mockReturnValue({ title: 'Patched' });
|
||||
const update = vi.fn().mockReturnValue({ title: 'Patched' });
|
||||
|
||||
const result = await patchThread(memory, { threadId: 'thread-1', update });
|
||||
|
||||
|
|
@ -64,10 +66,10 @@ describe('patchThread', () => {
|
|||
describe('native getThread + saveThread fallback', () => {
|
||||
it('reads thread, calls update, then saves', async () => {
|
||||
const memory = makeMemory({
|
||||
getThread: jest.fn().mockResolvedValue({ ...baseThread }),
|
||||
saveThread: jest.fn(),
|
||||
getThread: vi.fn().mockResolvedValue({ ...baseThread }),
|
||||
saveThread: vi.fn(),
|
||||
});
|
||||
const update = jest.fn().mockReturnValue({ title: 'Updated Title' });
|
||||
const update = vi.fn().mockReturnValue({ title: 'Updated Title' });
|
||||
|
||||
const result = await patchThread(memory, { threadId: 'thread-1', update });
|
||||
|
||||
|
|
@ -87,7 +89,7 @@ describe('patchThread', () => {
|
|||
|
||||
it('returns unchanged thread when update returns null', async () => {
|
||||
const memory = makeMemory();
|
||||
const update = jest.fn().mockReturnValue(null);
|
||||
const update = vi.fn().mockReturnValue(null);
|
||||
|
||||
const result = await patchThread(memory, { threadId: 'thread-1', update });
|
||||
|
||||
|
|
@ -97,9 +99,9 @@ describe('patchThread', () => {
|
|||
|
||||
it('returns null when thread does not exist', async () => {
|
||||
const memory = makeMemory({
|
||||
getThread: jest.fn().mockResolvedValue(null),
|
||||
getThread: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
const update = jest.fn();
|
||||
const update = vi.fn();
|
||||
|
||||
const result = await patchThread(memory, { threadId: 'unknown', update });
|
||||
|
||||
|
|
@ -109,12 +111,12 @@ describe('patchThread', () => {
|
|||
|
||||
it('uses threadId as title fallback when thread has no title', async () => {
|
||||
const memory = makeMemory({
|
||||
getThread: jest.fn().mockResolvedValue({
|
||||
getThread: vi.fn().mockResolvedValue({
|
||||
...baseThread,
|
||||
title: undefined,
|
||||
}),
|
||||
});
|
||||
const update = jest.fn().mockReturnValue({ metadata: { newKey: 'newVal' } });
|
||||
const update = vi.fn().mockReturnValue({ metadata: { newKey: 'newVal' } });
|
||||
|
||||
await patchThread(memory, { threadId: 'thread-1', update });
|
||||
|
||||
|
|
@ -125,7 +127,7 @@ describe('patchThread', () => {
|
|||
|
||||
it('applies metadata from patch when provided', async () => {
|
||||
const memory = makeMemory();
|
||||
const update = jest.fn().mockReturnValue({ metadata: { newKey: 'newVal' } });
|
||||
const update = vi.fn().mockReturnValue({ metadata: { newKey: 'newVal' } });
|
||||
|
||||
await patchThread(memory, { threadId: 'thread-1', update });
|
||||
|
||||
|
|
@ -136,7 +138,7 @@ describe('patchThread', () => {
|
|||
|
||||
it('passes a defensive copy of metadata to update function', async () => {
|
||||
const memory = makeMemory();
|
||||
const update = jest.fn().mockImplementation((thread: ThreadRecord) => {
|
||||
const update = vi.fn().mockImplementation((thread: ThreadRecord) => {
|
||||
thread.metadata = { ...(thread.metadata ?? {}), mutated: true };
|
||||
return { title: 'Updated' };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,27 +1,26 @@
|
|||
import type { TaskList } from '@n8n/api-types';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { PatchableThreadMemory } from '../thread-patch';
|
||||
import type * as ThreadPatch from '../thread-patch';
|
||||
import { patchThread } from '../thread-patch';
|
||||
import { ThreadTaskStorage } from '../thread-task-storage';
|
||||
|
||||
jest.mock('../thread-patch', () => {
|
||||
const actual =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
jest.requireActual<typeof ThreadPatch>('../thread-patch');
|
||||
vi.mock('../thread-patch', async () => {
|
||||
const actual = await vi.importActual<typeof ThreadPatch>('../thread-patch');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
patchThread: jest.fn(),
|
||||
patchThread: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedPatchThread = jest.mocked(patchThread);
|
||||
type TestMemory = PatchableThreadMemory & { getThread: jest.Mock };
|
||||
const mockedPatchThread = vi.mocked(patchThread);
|
||||
type TestMemory = PatchableThreadMemory & { getThread: Mock };
|
||||
|
||||
function makeMemory(): TestMemory {
|
||||
return {
|
||||
getThread: jest.fn(),
|
||||
getThread: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -40,14 +39,14 @@ describe('ThreadTaskStorage', () => {
|
|||
let storage: ThreadTaskStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
memory = makeMemory();
|
||||
storage = new ThreadTaskStorage(memory);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns task list from thread metadata', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
id: 'thread-1',
|
||||
title: 'Test',
|
||||
metadata: { instanceAiTasks: sampleTaskList },
|
||||
|
|
@ -61,7 +60,7 @@ describe('ThreadTaskStorage', () => {
|
|||
});
|
||||
|
||||
it('returns null when no tasks in metadata', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
id: 'thread-1',
|
||||
title: 'Test',
|
||||
metadata: {},
|
||||
|
|
@ -74,13 +73,13 @@ describe('ThreadTaskStorage', () => {
|
|||
});
|
||||
|
||||
it('returns null when thread not found', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue(null);
|
||||
vi.mocked(memory.getThread).mockResolvedValue(null);
|
||||
|
||||
expect(await storage.get('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when metadata fails Zod validation', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
id: 'thread-1',
|
||||
title: 'Test',
|
||||
metadata: { instanceAiTasks: 'invalid-data' },
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { WorkflowLoopState, AttemptRecord } from '../../workflow-loop/workflow-loop-state';
|
||||
import { patchThread, type PatchableThreadMemory } from '../thread-patch';
|
||||
import type * as ThreadPatch from '../thread-patch';
|
||||
import { WorkflowLoopStorage } from '../workflow-loop-storage';
|
||||
|
||||
jest.mock('../thread-patch', () => {
|
||||
const actual =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
jest.requireActual<typeof ThreadPatch>('../thread-patch');
|
||||
vi.mock('../thread-patch', async () => {
|
||||
const actual = await vi.importActual<typeof ThreadPatch>('../thread-patch');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
patchThread: jest.fn(),
|
||||
patchThread: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedPatchThread = jest.mocked(patchThread);
|
||||
type TestMemory = PatchableThreadMemory & { getThread: jest.Mock };
|
||||
const mockedPatchThread = vi.mocked(patchThread);
|
||||
type TestMemory = PatchableThreadMemory & { getThread: Mock };
|
||||
|
||||
function makeMemory(): TestMemory {
|
||||
return {
|
||||
getThread: jest.fn(),
|
||||
getThread: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ describe('WorkflowLoopStorage', () => {
|
|||
let storage: WorkflowLoopStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
memory = makeMemory();
|
||||
storage = new WorkflowLoopStorage(memory);
|
||||
});
|
||||
|
|
@ -69,7 +69,7 @@ describe('WorkflowLoopStorage', () => {
|
|||
it('returns work item from thread metadata', async () => {
|
||||
const state = makeState();
|
||||
const attempts = [makeAttempt()];
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
...baseThread,
|
||||
metadata: {
|
||||
instanceAiWorkflowLoop: {
|
||||
|
|
@ -84,7 +84,7 @@ describe('WorkflowLoopStorage', () => {
|
|||
});
|
||||
|
||||
it('returns null for unknown work item', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
...baseThread,
|
||||
metadata: {
|
||||
instanceAiWorkflowLoop: {},
|
||||
|
|
@ -95,7 +95,7 @@ describe('WorkflowLoopStorage', () => {
|
|||
});
|
||||
|
||||
it('returns null when no loop metadata exists', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
...baseThread,
|
||||
metadata: {},
|
||||
});
|
||||
|
|
@ -152,7 +152,7 @@ describe('WorkflowLoopStorage', () => {
|
|||
const activeState = makeState({ workItemId: 'wi-active', status: 'active' });
|
||||
const doneState = makeState({ workItemId: 'wi-done', status: 'completed' });
|
||||
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
...baseThread,
|
||||
metadata: {
|
||||
instanceAiWorkflowLoop: {
|
||||
|
|
@ -169,7 +169,7 @@ describe('WorkflowLoopStorage', () => {
|
|||
it('returns null when no active work item exists', async () => {
|
||||
const doneState = makeState({ workItemId: 'wi-done', status: 'completed' });
|
||||
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
...baseThread,
|
||||
metadata: {
|
||||
instanceAiWorkflowLoop: {
|
||||
|
|
@ -182,7 +182,7 @@ describe('WorkflowLoopStorage', () => {
|
|||
});
|
||||
|
||||
it('returns null when no loop metadata', async () => {
|
||||
jest.mocked(memory.getThread).mockResolvedValue({
|
||||
vi.mocked(memory.getThread).mockResolvedValue({
|
||||
...baseThread,
|
||||
metadata: {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { InstanceAiPermissions } from '@n8n/api-types';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import type { InstanceAiContext, CredentialSummary, CredentialDetail } from '../../types';
|
||||
|
|
@ -18,13 +19,13 @@ function createMockContext(
|
|||
nodeService: {} as InstanceAiContext['nodeService'],
|
||||
dataTableService: {} as InstanceAiContext['dataTableService'],
|
||||
credentialService: {
|
||||
list: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn().mockResolvedValue({}),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
test: jest.fn().mockResolvedValue({ success: true }),
|
||||
searchCredentialTypes: jest.fn().mockResolvedValue([]),
|
||||
getDocumentationUrl: jest.fn().mockResolvedValue(null),
|
||||
getCredentialFields: jest.fn().mockResolvedValue([]),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
test: vi.fn().mockResolvedValue({ success: true }),
|
||||
searchCredentialTypes: vi.fn().mockResolvedValue([]),
|
||||
getDocumentationUrl: vi.fn().mockResolvedValue(null),
|
||||
getCredentialFields: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
permissions: {},
|
||||
...overrides,
|
||||
|
|
@ -35,7 +36,7 @@ function noSuspendCtx() {
|
|||
return { resumeData: undefined, suspend: undefined } as never;
|
||||
}
|
||||
|
||||
function suspendCtx(suspendFn: jest.Mock = jest.fn()) {
|
||||
function suspendCtx(suspendFn: Mock = vi.fn()) {
|
||||
return {
|
||||
resumeData: undefined,
|
||||
suspend: suspendFn,
|
||||
|
|
@ -47,7 +48,7 @@ function resumeCtx(resumeData: {
|
|||
credentials?: Record<string, string>;
|
||||
autoSetup?: { credentialType: string };
|
||||
}) {
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
return { resumeData, suspend } as never;
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +153,7 @@ describe('credentials tool', () => {
|
|||
{ id: '3', name: 'Notion Key', type: 'notionApi' },
|
||||
];
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
|
||||
|
|
@ -172,7 +173,7 @@ describe('credentials tool', () => {
|
|||
it('should filter by type when provided', async () => {
|
||||
const credentials: CredentialSummary[] = [{ id: '1', name: 'Slack Token', type: 'slackApi' }];
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(tool, { action: 'list' as const, type: 'slackApi' }, noSuspendCtx());
|
||||
|
|
@ -187,7 +188,7 @@ describe('credentials tool', () => {
|
|||
type: 'testType',
|
||||
}));
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -214,7 +215,7 @@ describe('credentials tool', () => {
|
|||
type: 'testType',
|
||||
}));
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
|
||||
|
|
@ -230,7 +231,7 @@ describe('credentials tool', () => {
|
|||
{ id: '3', name: 'Notion Key', type: 'notionApi' },
|
||||
];
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -256,7 +257,7 @@ describe('credentials tool', () => {
|
|||
type: 'notionApi',
|
||||
}));
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -279,7 +280,7 @@ describe('credentials tool', () => {
|
|||
type: 'testType',
|
||||
}));
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
|
||||
|
|
@ -299,7 +300,7 @@ describe('credentials tool', () => {
|
|||
type: 'slackApi',
|
||||
}));
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -317,7 +318,7 @@ describe('credentials tool', () => {
|
|||
{ id: '1', name: 'Slack Token', type: 'slackApi', extraField: 'should-be-stripped' },
|
||||
];
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(credentials);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(credentials);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(tool, { action: 'list' as const }, noSuspendCtx());
|
||||
|
|
@ -339,7 +340,7 @@ describe('credentials tool', () => {
|
|||
nodesWithAccess: [{ nodeType: 'n8n-nodes-base.notion' }],
|
||||
};
|
||||
const context = createMockContext();
|
||||
(context.credentialService.get as jest.Mock).mockResolvedValue(detail);
|
||||
(context.credentialService.get as Mock).mockResolvedValue(detail);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -396,7 +397,7 @@ describe('credentials tool', () => {
|
|||
const context = createMockContext({
|
||||
permissions: { deleteCredential: 'require_approval' },
|
||||
});
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
|
|
@ -420,7 +421,7 @@ describe('credentials tool', () => {
|
|||
const context = createMockContext({
|
||||
permissions: { deleteCredential: 'require_approval' },
|
||||
});
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
|
|
@ -439,7 +440,7 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should suspend by default when permissions are not explicitly set', async () => {
|
||||
const context = createMockContext({ permissions: {} });
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
|
|
@ -498,9 +499,7 @@ describe('credentials tool', () => {
|
|||
{ type: 'slackOAuth2Api', displayName: 'Slack OAuth2 API' },
|
||||
];
|
||||
const context = createMockContext();
|
||||
(context.credentialService.searchCredentialTypes as jest.Mock).mockResolvedValue(
|
||||
searchResults,
|
||||
);
|
||||
(context.credentialService.searchCredentialTypes as Mock).mockResolvedValue(searchResults);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -526,9 +525,7 @@ describe('credentials tool', () => {
|
|||
{ type: 'oAuth2Api', displayName: 'OAuth2' },
|
||||
];
|
||||
const context = createMockContext();
|
||||
(context.credentialService.searchCredentialTypes as jest.Mock).mockResolvedValue(
|
||||
searchResults,
|
||||
);
|
||||
(context.credentialService.searchCredentialTypes as Mock).mockResolvedValue(searchResults);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -565,9 +562,9 @@ describe('credentials tool', () => {
|
|||
{ id: 'c1', name: 'Existing Slack', type: 'slackApi' },
|
||||
];
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue(existingCreds);
|
||||
(context.credentialService.list as Mock).mockResolvedValue(existingCreds);
|
||||
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
|
|
@ -598,9 +595,9 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should include suggestedName in credentialRequests when provided', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
|
|
@ -631,9 +628,9 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should use plural message for multiple credentials', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
|
|
@ -654,9 +651,9 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should include projectId in suspend payload when provided', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
|
|
@ -674,7 +671,7 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should scope credential lookup to projectId when provided', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
await tool.handler!(
|
||||
|
|
@ -683,7 +680,7 @@ describe('credentials tool', () => {
|
|||
credentials: [{ credentialType: 'slackApi' }],
|
||||
projectId: 'proj-1',
|
||||
},
|
||||
suspendCtx(jest.fn()),
|
||||
suspendCtx(vi.fn()),
|
||||
);
|
||||
|
||||
expect(context.credentialService.list).toHaveBeenCalledWith({
|
||||
|
|
@ -694,7 +691,7 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should omit projectId from credential lookup when not provided', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
await tool.handler!(
|
||||
|
|
@ -702,7 +699,7 @@ describe('credentials tool', () => {
|
|||
action: 'setup' as const,
|
||||
credentials: [{ credentialType: 'slackApi' }],
|
||||
},
|
||||
suspendCtx(jest.fn()),
|
||||
suspendCtx(vi.fn()),
|
||||
);
|
||||
|
||||
expect(context.credentialService.list).toHaveBeenCalledWith({ type: 'slackApi' });
|
||||
|
|
@ -710,9 +707,9 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should include credentialFlow in suspend payload for finalize stage', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
|
|
@ -774,10 +771,10 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should return needsBrowserSetup when autoSetup is present', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.getDocumentationUrl as jest.Mock).mockResolvedValue(
|
||||
(context.credentialService.getDocumentationUrl as Mock).mockResolvedValue(
|
||||
'https://docs.example.com/slack',
|
||||
);
|
||||
(context.credentialService.getCredentialFields as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.getCredentialFields as Mock).mockResolvedValue([
|
||||
{ name: 'apiKey', displayName: 'API Key', type: 'string', required: true },
|
||||
]);
|
||||
|
||||
|
|
@ -828,7 +825,7 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should return missing_credentials error when credentials is undefined', async () => {
|
||||
const context = createMockContext();
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await tool.handler!(
|
||||
|
|
@ -846,7 +843,7 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should return missing_credentials error when credentials is an empty array', async () => {
|
||||
const context = createMockContext();
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await tool.handler!(
|
||||
|
|
@ -863,9 +860,9 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should default reason when not provided in credential requests', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const suspendFn = jest.fn();
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createCredentialsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
|
|
@ -894,7 +891,7 @@ describe('credentials tool', () => {
|
|||
describe('test action', () => {
|
||||
it('should call credentialService.test and return result', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.test as jest.Mock).mockResolvedValue({
|
||||
(context.credentialService.test as Mock).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
});
|
||||
|
|
@ -912,9 +909,7 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should handle errors from credentialService.test', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.test as jest.Mock).mockRejectedValue(
|
||||
new Error('Connection refused'),
|
||||
);
|
||||
(context.credentialService.test as Mock).mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -931,7 +926,7 @@ describe('credentials tool', () => {
|
|||
|
||||
it('should handle non-Error throws from credentialService.test', async () => {
|
||||
const context = createMockContext();
|
||||
(context.credentialService.test as jest.Mock).mockRejectedValue('string error');
|
||||
(context.credentialService.test as Mock).mockRejectedValue('string error');
|
||||
|
||||
const tool = createCredentialsTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,105 +2,109 @@ import { createAllTools, createOrchestratorDomainTools } from '..';
|
|||
import { isParseableAttachment } from '../../parsers/structured-file-parser';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
|
||||
jest.mock('../../parsers/structured-file-parser', () => ({
|
||||
isParseableAttachment: jest.fn(() => false),
|
||||
vi.mock('../../parsers/structured-file-parser', () => ({
|
||||
isParseableAttachment: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('../attachments/parse-file.tool', () => ({
|
||||
createParseFileTool: jest.fn(() => ({ id: 'parse-file' })),
|
||||
vi.mock('../attachments/parse-file.tool', () => ({
|
||||
createParseFileTool: vi.fn(() => ({ id: 'parse-file' })),
|
||||
}));
|
||||
|
||||
jest.mock('../credentials.tool', () => ({
|
||||
vi.mock('../credentials.tool', () => ({
|
||||
CREDENTIALS_TOOL_ID: 'credentials',
|
||||
createCredentialsTool: jest.fn(() => ({ id: 'credentials' })),
|
||||
createCredentialsTool: vi.fn(() => ({ id: 'credentials' })),
|
||||
}));
|
||||
|
||||
jest.mock('../data-tables.tool', () => ({
|
||||
vi.mock('../data-tables.tool', () => ({
|
||||
DATA_TABLES_TOOL_ID: 'data-tables',
|
||||
createDataTablesTool: jest.fn((_context: unknown, scope?: string) => ({
|
||||
createDataTablesTool: vi.fn((_context: unknown, scope?: string) => ({
|
||||
id: scope ? `data-tables-${scope}` : 'data-tables',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../executions.tool', () => ({
|
||||
createExecutionsTool: jest.fn(() => ({ id: 'executions' })),
|
||||
vi.mock('../executions.tool', () => ({
|
||||
createExecutionsTool: vi.fn(() => ({ id: 'executions' })),
|
||||
}));
|
||||
|
||||
jest.mock('../nodes.tool', () => ({
|
||||
createNodesTool: jest.fn((_context: unknown, scope?: string) => ({
|
||||
vi.mock('../nodes.tool', () => ({
|
||||
createNodesTool: vi.fn((_context: unknown, scope?: string) => ({
|
||||
id: scope ? `nodes-${scope}` : 'nodes',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../orchestration/complete-checkpoint.tool', () => ({
|
||||
createCompleteCheckpointTool: jest.fn(() => ({ id: 'complete-checkpoint' })),
|
||||
vi.mock('../orchestration/build-workflow-agent.tool', () => ({
|
||||
createBuildWorkflowAgentTool: vi.fn(() => ({ id: 'build-workflow-with-agent' })),
|
||||
}));
|
||||
|
||||
jest.mock('../orchestration/delegate.tool', () => ({
|
||||
createDelegateTool: jest.fn(() => ({ id: 'delegate' })),
|
||||
vi.mock('../orchestration/complete-checkpoint.tool', () => ({
|
||||
createCompleteCheckpointTool: vi.fn(() => ({ id: 'complete-checkpoint' })),
|
||||
}));
|
||||
|
||||
jest.mock('../evals/evals.tool', () => ({
|
||||
createEvalsTool: jest.fn(() => ({ id: 'evals' })),
|
||||
vi.mock('../orchestration/delegate.tool', () => ({
|
||||
createDelegateTool: vi.fn(() => ({ id: 'delegate' })),
|
||||
}));
|
||||
|
||||
jest.mock('../orchestration/eval-setup-agent.tool', () => ({
|
||||
createEvalSetupAgentTool: jest.fn(() => ({ id: 'eval-setup-with-agent' })),
|
||||
vi.mock('../evals/evals.tool', () => ({
|
||||
createEvalsTool: vi.fn(() => ({ id: 'evals' })),
|
||||
}));
|
||||
|
||||
jest.mock('../orchestration/eval-data-agent.tool', () => ({
|
||||
createEvalDataAgentTool: jest.fn(() => ({ id: 'eval-data' })),
|
||||
vi.mock('../orchestration/eval-setup-agent.tool', () => ({
|
||||
createEvalSetupAgentTool: vi.fn(() => ({ id: 'eval-setup-with-agent' })),
|
||||
}));
|
||||
|
||||
jest.mock('../orchestration/plan-with-agent.tool', () => ({
|
||||
createPlanWithAgentTool: jest.fn(() => ({ id: 'plan' })),
|
||||
vi.mock('../orchestration/eval-data-agent.tool', () => ({
|
||||
createEvalDataAgentTool: vi.fn(() => ({ id: 'eval-data' })),
|
||||
}));
|
||||
|
||||
jest.mock('../orchestration/plan.tool', () => ({
|
||||
createPlanTool: jest.fn(() => ({ id: 'create-tasks' })),
|
||||
vi.mock('../orchestration/plan-with-agent.tool', () => ({
|
||||
createPlanWithAgentTool: vi.fn(() => ({ id: 'plan' })),
|
||||
}));
|
||||
|
||||
jest.mock('../orchestration/report-verification-verdict.tool', () => ({
|
||||
createReportVerificationVerdictTool: jest.fn(() => ({ id: 'report-verification-verdict' })),
|
||||
vi.mock('../orchestration/plan.tool', () => ({
|
||||
createPlanTool: vi.fn(() => ({ id: 'create-tasks' })),
|
||||
}));
|
||||
|
||||
jest.mock('../orchestration/verify-built-workflow.tool', () => ({
|
||||
createVerifyBuiltWorkflowTool: jest.fn(() => ({ id: 'verify-built-workflow' })),
|
||||
vi.mock('../orchestration/report-verification-verdict.tool', () => ({
|
||||
createReportVerificationVerdictTool: vi.fn(() => ({ id: 'report-verification-verdict' })),
|
||||
}));
|
||||
|
||||
jest.mock('../research.tool', () => ({
|
||||
createResearchTool: jest.fn(() => ({ id: 'research' })),
|
||||
vi.mock('../orchestration/verify-built-workflow.tool', () => ({
|
||||
createVerifyBuiltWorkflowTool: vi.fn(() => ({ id: 'verify-built-workflow' })),
|
||||
}));
|
||||
|
||||
jest.mock('../shared/ask-user.tool', () => ({
|
||||
vi.mock('../research.tool', () => ({
|
||||
createResearchTool: vi.fn(() => ({ id: 'research' })),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ask-user.tool', () => ({
|
||||
ASK_USER_TOOL_ID: 'ask-user',
|
||||
createAskUserTool: jest.fn(() => ({ id: 'ask-user' })),
|
||||
createAskUserTool: vi.fn(() => ({ id: 'ask-user' })),
|
||||
}));
|
||||
|
||||
jest.mock('../task-control.tool', () => ({
|
||||
createTaskControlTool: jest.fn(() => ({ id: 'task-control' })),
|
||||
vi.mock('../task-control.tool', () => ({
|
||||
createTaskControlTool: vi.fn(() => ({ id: 'task-control' })),
|
||||
}));
|
||||
|
||||
jest.mock('../workflows/apply-workflow-credentials.tool', () => ({
|
||||
createApplyWorkflowCredentialsTool: jest.fn(() => ({ id: 'apply-workflow-credentials' })),
|
||||
vi.mock('../workflows/apply-workflow-credentials.tool', () => ({
|
||||
createApplyWorkflowCredentialsTool: vi.fn(() => ({ id: 'apply-workflow-credentials' })),
|
||||
}));
|
||||
|
||||
jest.mock('../workflows/build-workflow.tool', () => ({
|
||||
createBuildWorkflowTool: jest.fn(() => ({ id: 'build-workflow' })),
|
||||
vi.mock('../workflows/build-workflow.tool', () => ({
|
||||
createBuildWorkflowTool: vi.fn(() => ({ id: 'build-workflow' })),
|
||||
}));
|
||||
|
||||
jest.mock('../workflows.tool', () => ({
|
||||
createWorkflowsTool: jest.fn((_context: unknown, options?: unknown) => ({
|
||||
vi.mock('../workflows.tool', () => ({
|
||||
createWorkflowsTool: vi.fn((_context: unknown, options?: unknown) => ({
|
||||
id: options ? 'workflows-filtered' : 'workflows',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../workspace.tool', () => ({
|
||||
createWorkspaceTool: jest.fn(() => ({ id: 'workspace' })),
|
||||
vi.mock('../workspace.tool', () => ({
|
||||
createWorkspaceTool: vi.fn(() => ({ id: 'workspace' })),
|
||||
}));
|
||||
|
||||
jest.mock('../filesystem/create-tools-from-mcp-server', () => ({
|
||||
createToolsFromLocalMcpServer: jest.fn(() => ({
|
||||
vi.mock('../filesystem/create-tools-from-mcp-server', () => ({
|
||||
createToolsFromLocalMcpServer: vi.fn(() => ({
|
||||
browser_connect: { id: 'browser_connect' },
|
||||
browser_navigate: { id: 'browser_navigate' },
|
||||
})),
|
||||
|
|
@ -109,15 +113,15 @@ jest.mock('../filesystem/create-tools-from-mcp-server', () => ({
|
|||
function makeContext(overrides: Partial<InstanceAiContext> = {}): InstanceAiContext {
|
||||
return {
|
||||
userId: 'user-a',
|
||||
logger: { warn: jest.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
...overrides,
|
||||
} as unknown as InstanceAiContext;
|
||||
}
|
||||
|
||||
describe('domain tool construction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(isParseableAttachment).mockReturnValue(false);
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isParseableAttachment).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('creates the native full domain tool map', () => {
|
||||
|
|
@ -139,7 +143,7 @@ describe('domain tool construction', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('creates the native orchestrator domain tool map', () => {
|
||||
it('creates the native orchestrator domain tool map', async () => {
|
||||
const context = makeContext();
|
||||
|
||||
const orchestratorTools = createOrchestratorDomainTools(context);
|
||||
|
|
@ -157,9 +161,9 @@ describe('domain tool construction', () => {
|
|||
'build-workflow': { id: 'build-workflow' },
|
||||
});
|
||||
|
||||
const { createWorkflowsTool } = jest.requireMock('../workflows.tool');
|
||||
const { createNodesTool } = jest.requireMock('../nodes.tool');
|
||||
const { createDataTablesTool } = jest.requireMock('../data-tables.tool');
|
||||
const { createWorkflowsTool } = await import('../workflows.tool');
|
||||
const { createNodesTool } = await import('../nodes.tool');
|
||||
const { createDataTablesTool } = await import('../data-tables.tool');
|
||||
expect(createWorkflowsTool).toHaveBeenCalledWith(context);
|
||||
expect(createNodesTool).toHaveBeenCalledWith(context);
|
||||
expect(createDataTablesTool).toHaveBeenCalledWith(context);
|
||||
|
|
@ -177,7 +181,7 @@ describe('domain tool construction', () => {
|
|||
});
|
||||
|
||||
it('includes parse-file tools when attachments are parseable', () => {
|
||||
jest.mocked(isParseableAttachment).mockReturnValue(true);
|
||||
vi.mocked(isParseableAttachment).mockReturnValue(true);
|
||||
const context = makeContext({
|
||||
currentUserAttachments: [{ data: '', mimeType: 'text/html', fileName: 'page.html' }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
import { createNodesTool } from '../nodes.tool';
|
||||
|
|
@ -6,49 +8,49 @@ function createMockContext(overrides: Partial<InstanceAiContext> = {}): Instance
|
|||
return {
|
||||
userId: 'user-1',
|
||||
workflowService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getAsWorkflowJSON: jest.fn(),
|
||||
createFromWorkflowJSON: jest.fn(),
|
||||
updateFromWorkflowJSON: jest.fn(),
|
||||
archive: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
unpublish: jest.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getAsWorkflowJSON: vi.fn(),
|
||||
createFromWorkflowJSON: vi.fn(),
|
||||
updateFromWorkflowJSON: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
unpublish: vi.fn(),
|
||||
},
|
||||
executionService: {
|
||||
list: jest.fn(),
|
||||
run: jest.fn(),
|
||||
getStatus: jest.fn(),
|
||||
getResult: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
getDebugInfo: jest.fn(),
|
||||
getNodeOutput: jest.fn(),
|
||||
list: vi.fn(),
|
||||
run: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getResult: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
getDebugInfo: vi.fn(),
|
||||
getNodeOutput: vi.fn(),
|
||||
},
|
||||
credentialService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
test: jest.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
test: vi.fn(),
|
||||
},
|
||||
nodeService: {
|
||||
listAvailable: jest.fn(),
|
||||
getDescription: jest.fn(),
|
||||
listSearchable: jest.fn(),
|
||||
exploreResources: jest.fn(),
|
||||
listAvailable: vi.fn(),
|
||||
getDescription: vi.fn(),
|
||||
listSearchable: vi.fn(),
|
||||
exploreResources: vi.fn(),
|
||||
},
|
||||
dataTableService: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
getSchema: jest.fn(),
|
||||
addColumn: jest.fn(),
|
||||
deleteColumn: jest.fn(),
|
||||
renameColumn: jest.fn(),
|
||||
queryRows: jest.fn(),
|
||||
insertRows: jest.fn(),
|
||||
updateRows: jest.fn(),
|
||||
deleteRows: jest.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getSchema: vi.fn(),
|
||||
addColumn: vi.fn(),
|
||||
deleteColumn: vi.fn(),
|
||||
renameColumn: vi.fn(),
|
||||
queryRows: vi.fn(),
|
||||
insertRows: vi.fn(),
|
||||
updateRows: vi.fn(),
|
||||
deleteRows: vi.fn(),
|
||||
},
|
||||
permissions: {},
|
||||
...overrides,
|
||||
|
|
@ -72,7 +74,7 @@ describe('nodes tool', () => {
|
|||
results: [{ name: 'Sheet1', value: 'sheet-1' }],
|
||||
paginationToken: undefined,
|
||||
};
|
||||
(context.nodeService.exploreResources as jest.Mock).mockResolvedValue(mockResult);
|
||||
(context.nodeService.exploreResources as Mock).mockResolvedValue(mockResult);
|
||||
|
||||
const tool = createNodesTool(context, 'orchestrator');
|
||||
const result = await executeTool(
|
||||
|
|
@ -119,7 +121,7 @@ describe('nodes tool', () => {
|
|||
},
|
||||
];
|
||||
const context = createMockContext();
|
||||
(context.nodeService.listAvailable as jest.Mock).mockResolvedValue(nodes);
|
||||
(context.nodeService.listAvailable as Mock).mockResolvedValue(nodes);
|
||||
|
||||
const tool = createNodesTool(context, 'full');
|
||||
const result = await executeTool(
|
||||
|
|
@ -161,9 +163,7 @@ describe('nodes tool', () => {
|
|||
|
||||
it('should handle errors from exploreResources gracefully', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.exploreResources as jest.Mock).mockRejectedValue(
|
||||
new Error('Auth failed'),
|
||||
);
|
||||
(context.nodeService.exploreResources as Mock).mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
const tool = createNodesTool(context, 'full');
|
||||
const result = await executeTool(
|
||||
|
|
@ -223,11 +223,11 @@ describe('nodes tool', () => {
|
|||
it('should surface node-level builder hints from type definitions', async () => {
|
||||
const context = createMockContext({
|
||||
nodeService: {
|
||||
listAvailable: jest.fn(),
|
||||
getDescription: jest.fn(),
|
||||
listSearchable: jest.fn(),
|
||||
exploreResources: jest.fn(),
|
||||
getNodeTypeDefinition: jest.fn().mockResolvedValue({
|
||||
listAvailable: vi.fn(),
|
||||
getDescription: vi.fn(),
|
||||
listSearchable: vi.fn(),
|
||||
exploreResources: vi.fn(),
|
||||
getNodeTypeDefinition: vi.fn().mockResolvedValue({
|
||||
content: 'export type IfNode = unknown;',
|
||||
version: 'v23',
|
||||
builderHint: 'Always include options, conditions, and combinator.',
|
||||
|
|
@ -258,7 +258,7 @@ describe('nodes tool', () => {
|
|||
describe('describe action', () => {
|
||||
it('should return found: false when node type is not found', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockRejectedValue(new Error('not found'));
|
||||
(context.nodeService.getDescription as Mock).mockRejectedValue(new Error('not found'));
|
||||
|
||||
const tool = createNodesTool(context, 'full');
|
||||
const result = await executeTool(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import { createToolRegistry } from '../../tool-registry';
|
||||
import type { OrchestrationContext } from '../../types';
|
||||
|
|
@ -14,18 +16,18 @@ function createMockContext(overrides: Partial<OrchestrationContext> = {}): Orche
|
|||
modelId: 'test-model',
|
||||
subAgentMaxSteps: 10,
|
||||
eventBus: {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
},
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() } as never,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never,
|
||||
domainTools: createToolRegistry(),
|
||||
abortSignal: new AbortController().signal,
|
||||
taskStorage: {
|
||||
get: jest.fn(),
|
||||
save: jest.fn(),
|
||||
get: vi.fn(),
|
||||
save: vi.fn(),
|
||||
},
|
||||
cancelBackgroundTask: jest.fn(),
|
||||
sendCorrectionToTask: jest.fn(),
|
||||
cancelBackgroundTask: vi.fn(),
|
||||
sendCorrectionToTask: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as OrchestrationContext;
|
||||
}
|
||||
|
|
@ -116,7 +118,7 @@ describe('task-control tool', () => {
|
|||
describe('correct-task action', () => {
|
||||
it('should send correction and return success message', async () => {
|
||||
const context = createMockContext();
|
||||
(context.sendCorrectionToTask as jest.Mock).mockReturnValue('queued');
|
||||
(context.sendCorrectionToTask as Mock).mockReturnValue('queued');
|
||||
|
||||
const tool = createTaskControlTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -142,7 +144,7 @@ describe('task-control tool', () => {
|
|||
|
||||
it('should return task-not-found message when task does not exist', async () => {
|
||||
const context = createMockContext();
|
||||
(context.sendCorrectionToTask as jest.Mock).mockReturnValue('task-not-found');
|
||||
(context.sendCorrectionToTask as Mock).mockReturnValue('task-not-found');
|
||||
|
||||
const tool = createTaskControlTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
@ -162,7 +164,7 @@ describe('task-control tool', () => {
|
|||
|
||||
it('should return task-completed message when task has finished', async () => {
|
||||
const context = createMockContext();
|
||||
(context.sendCorrectionToTask as jest.Mock).mockReturnValue('task-completed');
|
||||
(context.sendCorrectionToTask as Mock).mockReturnValue('task-completed');
|
||||
|
||||
const tool = createTaskControlTool(context);
|
||||
const result = await executeTool(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createTemplatesTool } from '../templates.tool';
|
|||
|
||||
describe('templates tool', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('best-practices action', () => {
|
||||
|
|
|
|||
|
|
@ -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' }, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,51 +14,51 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiCo
|
|||
return {
|
||||
userId: 'test-user',
|
||||
workflowService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getAsWorkflowJSON: jest.fn(),
|
||||
createFromWorkflowJSON: jest.fn(),
|
||||
updateFromWorkflowJSON: jest.fn(),
|
||||
archive: jest.fn(),
|
||||
unarchive: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
unpublish: jest.fn(),
|
||||
clearAiTemporary: jest.fn(),
|
||||
archiveIfAiTemporary: jest.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getAsWorkflowJSON: vi.fn(),
|
||||
createFromWorkflowJSON: vi.fn(),
|
||||
updateFromWorkflowJSON: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
unarchive: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
unpublish: vi.fn(),
|
||||
clearAiTemporary: vi.fn(),
|
||||
archiveIfAiTemporary: vi.fn(),
|
||||
},
|
||||
executionService: {
|
||||
list: jest.fn(),
|
||||
run: jest.fn(),
|
||||
getStatus: jest.fn(),
|
||||
getResult: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
getDebugInfo: jest.fn(),
|
||||
getNodeOutput: jest.fn(),
|
||||
getResolvedNodeParameters: jest.fn(),
|
||||
list: vi.fn(),
|
||||
run: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getResult: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
getDebugInfo: vi.fn(),
|
||||
getNodeOutput: vi.fn(),
|
||||
getResolvedNodeParameters: vi.fn(),
|
||||
},
|
||||
credentialService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
test: jest.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
test: vi.fn(),
|
||||
},
|
||||
nodeService: {
|
||||
listAvailable: jest.fn(),
|
||||
getDescription: jest.fn(),
|
||||
listSearchable: jest.fn(),
|
||||
listAvailable: vi.fn(),
|
||||
getDescription: vi.fn(),
|
||||
listSearchable: vi.fn(),
|
||||
},
|
||||
dataTableService: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
getSchema: jest.fn(),
|
||||
addColumn: jest.fn(),
|
||||
deleteColumn: jest.fn(),
|
||||
renameColumn: jest.fn(),
|
||||
queryRows: jest.fn(),
|
||||
insertRows: jest.fn(),
|
||||
updateRows: jest.fn(),
|
||||
deleteRows: jest.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getSchema: vi.fn(),
|
||||
addColumn: vi.fn(),
|
||||
deleteColumn: vi.fn(),
|
||||
renameColumn: vi.fn(),
|
||||
queryRows: vi.fn(),
|
||||
insertRows: vi.fn(),
|
||||
updateRows: vi.fn(),
|
||||
deleteRows: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,16 +18,19 @@ const createContext = (
|
|||
|
||||
describe('createEmptyEvalDataTable', () => {
|
||||
it('creates a string-typed table with the requested columns', async () => {
|
||||
const create = jest
|
||||
const create = vi
|
||||
.fn<
|
||||
ReturnType<InstanceAiContext['dataTableService']['create']>,
|
||||
Parameters<InstanceAiContext['dataTableService']['create']>
|
||||
(
|
||||
...args: Parameters<InstanceAiContext['dataTableService']['create']>
|
||||
) => ReturnType<InstanceAiContext['dataTableService']['create']>
|
||||
>()
|
||||
.mockResolvedValue(dataTableSummary('dt-1', 'Wf — eval samples'));
|
||||
const insertRows = jest.fn<
|
||||
ReturnType<InstanceAiContext['dataTableService']['insertRows']>,
|
||||
Parameters<InstanceAiContext['dataTableService']['insertRows']>
|
||||
>();
|
||||
const insertRows =
|
||||
vi.fn<
|
||||
(
|
||||
...args: Parameters<InstanceAiContext['dataTableService']['insertRows']>
|
||||
) => ReturnType<InstanceAiContext['dataTableService']['insertRows']>
|
||||
>();
|
||||
const ctx = createContext({ create, insertRows });
|
||||
|
||||
const result = await createEmptyEvalDataTable(ctx, {
|
||||
|
|
@ -49,18 +52,21 @@ describe('createEmptyEvalDataTable', () => {
|
|||
});
|
||||
|
||||
it('normalizes requested columns to valid DataTable names', async () => {
|
||||
const create = jest
|
||||
const create = vi
|
||||
.fn<
|
||||
ReturnType<InstanceAiContext['dataTableService']['create']>,
|
||||
Parameters<InstanceAiContext['dataTableService']['create']>
|
||||
(
|
||||
...args: Parameters<InstanceAiContext['dataTableService']['create']>
|
||||
) => ReturnType<InstanceAiContext['dataTableService']['create']>
|
||||
>()
|
||||
.mockResolvedValue(dataTableSummary('dt-1', 'Wf — eval samples'));
|
||||
const ctx = createContext({
|
||||
create,
|
||||
insertRows: jest.fn<
|
||||
ReturnType<InstanceAiContext['dataTableService']['insertRows']>,
|
||||
Parameters<InstanceAiContext['dataTableService']['insertRows']>
|
||||
>(),
|
||||
insertRows:
|
||||
vi.fn<
|
||||
(
|
||||
...args: Parameters<InstanceAiContext['dataTableService']['insertRows']>
|
||||
) => ReturnType<InstanceAiContext['dataTableService']['insertRows']>
|
||||
>(),
|
||||
});
|
||||
|
||||
await createEmptyEvalDataTable(ctx, {
|
||||
|
|
@ -80,19 +86,22 @@ describe('createEmptyEvalDataTable', () => {
|
|||
});
|
||||
|
||||
it('retries with a nanoid suffix on name collision', async () => {
|
||||
const create = jest
|
||||
const create = vi
|
||||
.fn<
|
||||
ReturnType<InstanceAiContext['dataTableService']['create']>,
|
||||
Parameters<InstanceAiContext['dataTableService']['create']>
|
||||
(
|
||||
...args: Parameters<InstanceAiContext['dataTableService']['create']>
|
||||
) => ReturnType<InstanceAiContext['dataTableService']['create']>
|
||||
>()
|
||||
.mockRejectedValueOnce(new Error('Data table already exists'))
|
||||
.mockResolvedValueOnce(dataTableSummary('dt-2', 'Wf — eval samples (abc12)'));
|
||||
const ctx = createContext({
|
||||
create,
|
||||
insertRows: jest.fn<
|
||||
ReturnType<InstanceAiContext['dataTableService']['insertRows']>,
|
||||
Parameters<InstanceAiContext['dataTableService']['insertRows']>
|
||||
>(),
|
||||
insertRows:
|
||||
vi.fn<
|
||||
(
|
||||
...args: Parameters<InstanceAiContext['dataTableService']['insertRows']>
|
||||
) => ReturnType<InstanceAiContext['dataTableService']['insertRows']>
|
||||
>(),
|
||||
});
|
||||
|
||||
const result = await createEmptyEvalDataTable(ctx, {
|
||||
|
|
@ -104,18 +113,21 @@ describe('createEmptyEvalDataTable', () => {
|
|||
});
|
||||
|
||||
it('rethrows non-collision errors', async () => {
|
||||
const create = jest
|
||||
const create = vi
|
||||
.fn<
|
||||
ReturnType<InstanceAiContext['dataTableService']['create']>,
|
||||
Parameters<InstanceAiContext['dataTableService']['create']>
|
||||
(
|
||||
...args: Parameters<InstanceAiContext['dataTableService']['create']>
|
||||
) => ReturnType<InstanceAiContext['dataTableService']['create']>
|
||||
>()
|
||||
.mockRejectedValueOnce(new Error('database down'));
|
||||
const ctx = createContext({
|
||||
create,
|
||||
insertRows: jest.fn<
|
||||
ReturnType<InstanceAiContext['dataTableService']['insertRows']>,
|
||||
Parameters<InstanceAiContext['dataTableService']['insertRows']>
|
||||
>(),
|
||||
insertRows:
|
||||
vi.fn<
|
||||
(
|
||||
...args: Parameters<InstanceAiContext['dataTableService']['insertRows']>
|
||||
) => ReturnType<InstanceAiContext['dataTableService']['insertRows']>
|
||||
>(),
|
||||
});
|
||||
await expect(
|
||||
createEmptyEvalDataTable(ctx, { workflowName: 'Wf', columns: ['x'] }),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import type { Mock, MockedFunction } from 'vitest';
|
||||
|
||||
jest.mock('../generate-tool-ref-pin-data.service', () => ({
|
||||
generateToolRefPinData: jest.fn().mockResolvedValue({}),
|
||||
vi.mock('../generate-tool-ref-pin-data.service', () => ({
|
||||
generateToolRefPinData: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
import type { InstanceAiContext } from '../../../types';
|
||||
import { createEvalsTool } from '../evals.tool';
|
||||
import { generateToolRefPinData } from '../generate-tool-ref-pin-data.service';
|
||||
|
||||
const mockGenerateToolRefPinData = generateToolRefPinData as jest.MockedFunction<
|
||||
const mockGenerateToolRefPinData = generateToolRefPinData as MockedFunction<
|
||||
typeof generateToolRefPinData
|
||||
>;
|
||||
|
||||
|
|
@ -217,33 +218,33 @@ function makeCtx(
|
|||
return {
|
||||
userId: 'u1',
|
||||
workflowService: {
|
||||
getAsWorkflowJSON: jest.fn().mockResolvedValue(wf),
|
||||
updateFromWorkflowJSON: jest.fn().mockResolvedValue(undefined),
|
||||
getAsWorkflowJSON: vi.fn().mockResolvedValue(wf),
|
||||
updateFromWorkflowJSON: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
dataTableService: {
|
||||
create: jest.fn().mockResolvedValue({
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: 'dt-new',
|
||||
name: 'AI Flow — eval samples',
|
||||
projectId: 'p-from-service',
|
||||
columns: [],
|
||||
}),
|
||||
insertRows: jest.fn().mockResolvedValue({
|
||||
insertRows: vi.fn().mockResolvedValue({
|
||||
insertedCount: 0,
|
||||
dataTableId: 'dt-new',
|
||||
tableName: 'x',
|
||||
projectId: 'p',
|
||||
}),
|
||||
queryRows: jest.fn().mockResolvedValue({ count: 0, data: [] }),
|
||||
queryRows: vi.fn().mockResolvedValue({ count: 0, data: [] }),
|
||||
...dataTableOverrides,
|
||||
},
|
||||
executionService: {} as never,
|
||||
credentialService: {} as never,
|
||||
nodeService: {} as never,
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
} as unknown as InstanceAiContext;
|
||||
}
|
||||
|
|
@ -251,7 +252,7 @@ function makeCtx(
|
|||
// ── action: offer ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('evalsTool — action: offer (eligibility precheck + chat message)', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns eligible:false with reason no-ai-nodes and never suspends for a non-AI workflow', async () => {
|
||||
const wf = {
|
||||
|
|
@ -270,7 +271,7 @@ describe('evalsTool — action: offer (eligibility precheck + chat message)', ()
|
|||
} as unknown as WorkflowJSON;
|
||||
const ctx = makeCtx(wf);
|
||||
const tool = createEvalsTool(ctx);
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
|
||||
const result = (await tool.handler!({ action: 'offer', workflowId: 'w1' }, {
|
||||
agent: { suspend, resumeData: undefined },
|
||||
|
|
@ -283,7 +284,7 @@ describe('evalsTool — action: offer (eligibility precheck + chat message)', ()
|
|||
it('returns eligible:false with reason already-configured when EvaluationTrigger is present', async () => {
|
||||
const ctx = makeCtx(evalConfiguredWf());
|
||||
const tool = createEvalsTool(ctx);
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
|
||||
const result = (await tool.handler!({ action: 'offer', workflowId: 'w1' }, {
|
||||
agent: { suspend, resumeData: undefined },
|
||||
|
|
@ -296,7 +297,7 @@ describe('evalsTool — action: offer (eligibility precheck + chat message)', ()
|
|||
it('returns eligible:true with aiNodeNames and a chat-ready message, never suspends', async () => {
|
||||
const ctx = makeCtx(aiWf());
|
||||
const tool = createEvalsTool(ctx);
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
|
||||
const result = (await tool.handler!({ action: 'offer', workflowId: 'w1' }, {
|
||||
agent: { suspend, resumeData: undefined },
|
||||
|
|
@ -330,7 +331,7 @@ describe('evalsTool — action: offer (eligibility precheck + chat message)', ()
|
|||
// ── action: recommend-metric ───────────────────────────────────────────────
|
||||
|
||||
describe('evals tool — recommend-metric action', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns { approved: true, metricId } when user approves the recommendation', async () => {
|
||||
// Agent has ai_tool connection → recommended is 'tool_use'
|
||||
|
|
@ -359,7 +360,7 @@ describe('evals tool — recommend-metric action', () => {
|
|||
// Plain agent (no tools / retrievers) → recommended is 'correctness'
|
||||
const ctx = makeCtx(aiWf());
|
||||
const tool = createEvalsTool(ctx);
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
|
||||
await tool.handler!({ action: 'recommend-metric', workflowId: 'w1' }, {
|
||||
agent: { suspend, resumeData: undefined },
|
||||
|
|
@ -382,7 +383,7 @@ describe('evals tool — recommend-metric action', () => {
|
|||
// ── action: select-metrics ─────────────────────────────────────────────────
|
||||
|
||||
describe('evals tool — select-metrics action', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns the workflow-default metric ids when user approves with default selections', async () => {
|
||||
// Agent has ai_tool connection → defaults are ['correctness', 'tool_use']
|
||||
|
|
@ -410,8 +411,8 @@ describe('evals tool — select-metrics action', () => {
|
|||
};
|
||||
const ctx = makeCtx(aiWfWithTools());
|
||||
const tool = createEvalsTool(ctx);
|
||||
const suspend = jest
|
||||
.fn<Promise<void>, [SelectMetricsSuspendPayload]>()
|
||||
const suspend = vi
|
||||
.fn<(...args: [SelectMetricsSuspendPayload]) => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await tool.handler!({ action: 'select-metrics', workflowId: 'w1' }, {
|
||||
|
|
@ -530,7 +531,7 @@ describe('evals tool — select-metrics action', () => {
|
|||
} as unknown as WorkflowJSON;
|
||||
const ctx = makeCtx(workflow);
|
||||
const tool = createEvalsTool(ctx);
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
await tool.handler!({ action: 'select-metrics', workflowId: 'w1' }, {
|
||||
agent: { suspend, resumeData: undefined },
|
||||
} as never);
|
||||
|
|
@ -553,7 +554,7 @@ describe('evals tool — select-metrics action', () => {
|
|||
// ── action: propose (changed) ──────────────────────────────────────────────
|
||||
|
||||
describe('evals tool — propose action (changed)', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('creates an EMPTY data table by default and never inserts rows', async () => {
|
||||
const ctx = makeCtx(aiWf());
|
||||
|
|
@ -609,7 +610,7 @@ describe('evals tool — propose action (changed)', () => {
|
|||
|
||||
it('uses input.projectId when the DataTable service does not return one', async () => {
|
||||
const ctx = makeCtx(aiWf(), {
|
||||
create: jest.fn().mockResolvedValue({
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: 'dt-new',
|
||||
name: 'AI Flow — eval samples',
|
||||
columns: [],
|
||||
|
|
@ -818,7 +819,7 @@ describe('evals tool — propose action (changed)', () => {
|
|||
// ── action: offer with named refs ──────────────────────────────────────────
|
||||
|
||||
describe('evals tool — offer with named refs', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('expands the offer message with disclosure when agent has named refs', async () => {
|
||||
const ctx = makeCtx(aiWfWithNamedRef());
|
||||
|
|
@ -925,7 +926,7 @@ function aiWfWithTwoToolRefs(): WorkflowJSON {
|
|||
|
||||
describe('evals tool — propose with tool-ref pinData', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockGenerateToolRefPinData.mockResolvedValue({});
|
||||
});
|
||||
|
||||
|
|
@ -940,7 +941,7 @@ describe('evals tool — propose with tool-ref pinData', () => {
|
|||
agent: {},
|
||||
} as never);
|
||||
|
||||
const update = ctx.workflowService.updateFromWorkflowJSON as jest.Mock;
|
||||
const update = ctx.workflowService.updateFromWorkflowJSON as Mock;
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'w1',
|
||||
|
|
@ -957,7 +958,7 @@ describe('evals tool — propose with tool-ref pinData', () => {
|
|||
mockGenerateToolRefPinData.mockResolvedValue({
|
||||
'Telegram Trigger': [{ json: { chat_id: '42' } }],
|
||||
});
|
||||
const create = jest.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
|
||||
const create = vi.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
|
||||
const ctx = makeCtx(aiWfWithToolRef(), { create });
|
||||
const tool = createEvalsTool(ctx);
|
||||
|
||||
|
|
@ -982,7 +983,7 @@ describe('evals tool — propose with tool-ref pinData', () => {
|
|||
mockGenerateToolRefPinData.mockResolvedValue({
|
||||
'Telegram Trigger': [{ json: { chat_id: '42' } }],
|
||||
});
|
||||
const create = jest.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
|
||||
const create = vi.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
|
||||
const ctx = makeCtx(aiWfWithTwoToolRefs(), { create });
|
||||
const tool = createEvalsTool(ctx);
|
||||
|
||||
|
|
@ -1028,10 +1029,10 @@ describe('evals tool — propose with tool-ref pinData', () => {
|
|||
});
|
||||
|
||||
describe('evals tool — propose with named refs', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('includes named-ref columns in the DataTable schema', async () => {
|
||||
const create = jest
|
||||
const create = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'dt-1', name: 'Wf — eval samples', columns: [] });
|
||||
const ctx = makeCtx(aiWfWithNamedRef(), { create });
|
||||
|
|
@ -1049,7 +1050,7 @@ describe('evals tool — propose with named refs', () => {
|
|||
});
|
||||
|
||||
it('combines direct $json refs and named-ref columns in DataTable', async () => {
|
||||
const create = jest.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
|
||||
const create = vi.fn().mockResolvedValue({ id: 'dt-1', name: 'x', columns: [] });
|
||||
const ctx = makeCtx(aiWfWithDirectAndNamedRef(), { create });
|
||||
const tool = createEvalsTool(ctx);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,17 @@ const buildContext = (
|
|||
): InstanceAiContext =>
|
||||
({
|
||||
executionService: {
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
list: vi
|
||||
.fn<
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValue([]),
|
||||
getNodeOutput: jest.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
>(),
|
||||
getNodeOutput:
|
||||
vi.fn<
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>(),
|
||||
...executionService,
|
||||
},
|
||||
}) as unknown as InstanceAiContext;
|
||||
|
|
@ -84,13 +88,16 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
|
||||
it('extracts agent-input rows from successful executions', async () => {
|
||||
const ctx = buildContext({
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
.mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]),
|
||||
getNodeOutput: jest
|
||||
list: vi
|
||||
.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]),
|
||||
getNodeOutput: vi
|
||||
.fn<
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>()
|
||||
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'hello' }))
|
||||
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'world' })),
|
||||
|
|
@ -110,17 +117,20 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
|
||||
it('deduplicates exact-match rows from execution history', async () => {
|
||||
const ctx = buildContext({
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
list: vi
|
||||
.fn<
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValueOnce([
|
||||
executionSummary('e1'),
|
||||
executionSummary('e2'),
|
||||
executionSummary('e3'),
|
||||
]),
|
||||
getNodeOutput: jest
|
||||
getNodeOutput: vi
|
||||
.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>()
|
||||
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'hello' }))
|
||||
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'hello' }))
|
||||
|
|
@ -141,13 +151,16 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
|
||||
it('skips executions where the projected record is missing a required column', async () => {
|
||||
const ctx = buildContext({
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
.mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]),
|
||||
getNodeOutput: jest
|
||||
list: vi
|
||||
.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValueOnce([executionSummary('e1'), executionSummary('e2')]),
|
||||
getNodeOutput: vi
|
||||
.fn<
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>()
|
||||
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'hello', context: 'c' }))
|
||||
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 'world' })),
|
||||
|
|
@ -166,13 +179,16 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
|
||||
it('coerces non-string column values to JSON strings', async () => {
|
||||
const ctx = buildContext({
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
.mockResolvedValueOnce([executionSummary('e1')]),
|
||||
getNodeOutput: jest
|
||||
list: vi
|
||||
.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValueOnce([executionSummary('e1')]),
|
||||
getNodeOutput: vi
|
||||
.fn<
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>()
|
||||
.mockResolvedValueOnce(nodeOutput('Trigger', { payload: { nested: 1 } })),
|
||||
});
|
||||
|
|
@ -192,12 +208,15 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
const summaries = Array.from({ length: 30 }, (_, i) => executionSummary(`e${i}`));
|
||||
const outputs = summaries.map((_, i) => nodeOutput('Trigger', { user_query: `q${i}` }));
|
||||
const ctx = buildContext({
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
list: vi
|
||||
.fn<
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValueOnce(summaries),
|
||||
getNodeOutput: jest.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
getNodeOutput: vi.fn<
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>(async () => await Promise.resolve(outputs.shift() ?? nodeOutput('Trigger', {}))),
|
||||
});
|
||||
|
||||
|
|
@ -213,15 +232,16 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
});
|
||||
|
||||
it('lists only successful executions', async () => {
|
||||
const list = jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
const list = vi
|
||||
.fn<(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>>()
|
||||
.mockResolvedValue([executionSummary('e1')]);
|
||||
const ctx = buildContext({
|
||||
list,
|
||||
getNodeOutput: jest
|
||||
getNodeOutput: vi
|
||||
.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>()
|
||||
.mockResolvedValueOnce(nodeOutput('Trigger', { user_query: 's' })),
|
||||
});
|
||||
|
|
@ -240,13 +260,16 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
|
||||
it('returns 0 rows and skips silently when getNodeOutput throws for an execution', async () => {
|
||||
const ctx = buildContext({
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
.mockResolvedValueOnce([executionSummary('e1')]),
|
||||
getNodeOutput: jest
|
||||
list: vi
|
||||
.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValueOnce([executionSummary('e1')]),
|
||||
getNodeOutput: vi
|
||||
.fn<
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>()
|
||||
.mockRejectedValueOnce(new Error('boom')),
|
||||
});
|
||||
|
|
@ -265,12 +288,15 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
|
||||
it('extracts expected columns from agent output when expectedToActualPairs are provided', async () => {
|
||||
const ctx = buildContext({
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
list: vi
|
||||
.fn<
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValueOnce([executionSummary('e1')]),
|
||||
getNodeOutput: jest.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
getNodeOutput: vi.fn<
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>(
|
||||
async (_id, nodeName) =>
|
||||
await Promise.resolve(
|
||||
|
|
@ -292,12 +318,15 @@ describe('extractRowsFromExecutionHistory', () => {
|
|||
|
||||
it('skips execution if the agent output is missing the actualField', async () => {
|
||||
const ctx = buildContext({
|
||||
list: jest
|
||||
.fn<ReturnType<ExecutionService['list']>, Parameters<ExecutionService['list']>>()
|
||||
list: vi
|
||||
.fn<
|
||||
(...args: Parameters<ExecutionService['list']>) => ReturnType<ExecutionService['list']>
|
||||
>()
|
||||
.mockResolvedValueOnce([executionSummary('e1')]),
|
||||
getNodeOutput: jest.fn<
|
||||
ReturnType<ExecutionService['getNodeOutput']>,
|
||||
Parameters<ExecutionService['getNodeOutput']>
|
||||
getNodeOutput: vi.fn<
|
||||
(
|
||||
...args: Parameters<ExecutionService['getNodeOutput']>
|
||||
) => ReturnType<ExecutionService['getNodeOutput']>
|
||||
>(
|
||||
async (_id, nodeName) =>
|
||||
await Promise.resolve(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import type { Mock, MockedFunction } from 'vitest';
|
||||
|
||||
jest.mock('../../../utils/eval-agents', () => {
|
||||
vi.mock('../../../utils/eval-agents', () => {
|
||||
return {
|
||||
createEvalAgent: jest.fn(),
|
||||
extractText: jest.fn(),
|
||||
createEvalAgent: vi.fn(),
|
||||
extractText: vi.fn(),
|
||||
HAIKU_MODEL: 'test-haiku-model',
|
||||
};
|
||||
});
|
||||
|
|
@ -17,21 +18,21 @@ import {
|
|||
} from '../generate-sample-rows.service';
|
||||
import type { AgentContext, SampleRowFacet } from '../generate-sample-rows.service';
|
||||
|
||||
const mockCreateEvalAgent = createEvalAgent as jest.MockedFunction<typeof createEvalAgent>;
|
||||
const mockExtractText = extractText as jest.MockedFunction<typeof extractText>;
|
||||
const mockCreateEvalAgent = createEvalAgent as MockedFunction<typeof createEvalAgent>;
|
||||
const mockExtractText = extractText as MockedFunction<typeof extractText>;
|
||||
|
||||
function setupAgentMock(responseText: string) {
|
||||
const generate = jest.fn().mockResolvedValue({ messages: [] });
|
||||
const generate = vi.fn().mockResolvedValue({ messages: [] });
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
mockExtractText.mockReturnValue(responseText);
|
||||
}
|
||||
|
||||
type GenerateMock = jest.Mock<Promise<{ messages: [] }>, [string]>;
|
||||
type GenerateMock = Mock<(...args: [string]) => Promise<{ messages: [] }>>;
|
||||
|
||||
function createGenerateMock(): GenerateMock {
|
||||
return jest.fn<Promise<{ messages: [] }>, [string]>().mockResolvedValue({ messages: [] });
|
||||
return vi.fn<(arg: string) => Promise<{ messages: [] }>>().mockResolvedValue({ messages: [] });
|
||||
}
|
||||
|
||||
function getPromptText(generate: GenerateMock): string {
|
||||
|
|
@ -43,7 +44,7 @@ function getPromptText(generate: GenerateMock): string {
|
|||
const WF: WorkflowJSON = { name: 'Test', nodes: [], connections: {} } as unknown as WorkflowJSON;
|
||||
|
||||
describe('generateSampleRows', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns parsed input rows from valid JSON across batches', async () => {
|
||||
setupAgentMock(JSON.stringify([{ input: 'q1' }]));
|
||||
|
|
@ -87,7 +88,7 @@ describe('generateSampleRows', () => {
|
|||
});
|
||||
|
||||
it('returns single fallback row when every batch rejects', async () => {
|
||||
const generate = jest.fn().mockRejectedValue(new Error('API down'));
|
||||
const generate = vi.fn().mockRejectedValue(new Error('API down'));
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
@ -135,7 +136,7 @@ const BATCH_CONTEXT: AgentContext = {
|
|||
};
|
||||
|
||||
describe('runBatch', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns parsed input rows on success', async () => {
|
||||
setupAgentMock(JSON.stringify([{ input: 'q1' }]));
|
||||
|
|
@ -179,7 +180,7 @@ describe('runBatch', () => {
|
|||
});
|
||||
|
||||
it('returns empty array when generate throws (does not propagate)', async () => {
|
||||
const generate = jest.fn().mockRejectedValue(new Error('API down'));
|
||||
const generate = vi.fn().mockRejectedValue(new Error('API down'));
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
@ -193,7 +194,7 @@ describe('runBatch', () => {
|
|||
});
|
||||
|
||||
it('logs and returns empty array when parsing fails', async () => {
|
||||
const logger = { warn: jest.fn<undefined, [string, Record<string, unknown>?]>() };
|
||||
const logger = { warn: vi.fn<(a: string, b?: Record<string, unknown>) => undefined>() };
|
||||
setupAgentMock('not json');
|
||||
const rows = await runBatch({
|
||||
facet: BATCH_FACET,
|
||||
|
|
@ -262,7 +263,7 @@ describe('runBatch', () => {
|
|||
});
|
||||
|
||||
it('returns empty array immediately when rowCount is zero', async () => {
|
||||
const generate = jest.fn();
|
||||
const generate = vi.fn();
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
@ -470,10 +471,10 @@ describe('extractAgentContext', () => {
|
|||
});
|
||||
|
||||
describe('generateSampleRows orchestration', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('dispatches one batch per non-empty facet', async () => {
|
||||
const generate = jest.fn().mockResolvedValue({ messages: [] });
|
||||
const generate = vi.fn().mockResolvedValue({ messages: [] });
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
@ -483,7 +484,7 @@ describe('generateSampleRows orchestration', () => {
|
|||
});
|
||||
|
||||
it('skips facets that get zero rows', async () => {
|
||||
const generate = jest.fn().mockResolvedValue({ messages: [] });
|
||||
const generate = vi.fn().mockResolvedValue({ messages: [] });
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
@ -500,7 +501,7 @@ describe('generateSampleRows orchestration', () => {
|
|||
Promise.resolve({ messages: [] }),
|
||||
Promise.resolve({ messages: [] }),
|
||||
];
|
||||
const generate = jest.fn().mockImplementation(async () => await responses.shift());
|
||||
const generate = vi.fn().mockImplementation(async () => await responses.shift());
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
@ -519,7 +520,7 @@ describe('generateSampleRows orchestration', () => {
|
|||
});
|
||||
|
||||
it('uses the default rowCount of 25 when not specified', async () => {
|
||||
const generate = jest.fn().mockResolvedValue({ messages: [] });
|
||||
const generate = vi.fn().mockResolvedValue({ messages: [] });
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import type { MockedFunction } from 'vitest';
|
||||
|
||||
jest.mock('../../../utils/eval-agents', () => {
|
||||
const actual: object = jest.requireActual('../../../utils/eval-agents');
|
||||
return { ...actual, createEvalAgent: jest.fn(), extractText: jest.fn() };
|
||||
vi.mock('../../../utils/eval-agents', async () => {
|
||||
const actual: object = await vi.importActual('../../../utils/eval-agents');
|
||||
return { ...actual, createEvalAgent: vi.fn(), extractText: vi.fn() };
|
||||
});
|
||||
|
||||
import { createEvalAgent, extractText } from '../../../utils/eval-agents';
|
||||
import type { ToolRef } from '../detect-tool-refs.service';
|
||||
import { generateToolRefPinData } from '../generate-tool-ref-pin-data.service';
|
||||
|
||||
const mockCreateEvalAgent = createEvalAgent as jest.MockedFunction<typeof createEvalAgent>;
|
||||
const mockExtractText = extractText as jest.MockedFunction<typeof extractText>;
|
||||
const mockCreateEvalAgent = createEvalAgent as MockedFunction<typeof createEvalAgent>;
|
||||
const mockExtractText = extractText as MockedFunction<typeof extractText>;
|
||||
|
||||
function setupAgentMock(responseText: string) {
|
||||
const generate = jest.fn().mockResolvedValue({ messages: [] });
|
||||
const generate = vi.fn().mockResolvedValue({ messages: [] });
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
@ -48,7 +49,7 @@ const agentNode: WorkflowJSON['nodes'][number] = {
|
|||
} as WorkflowJSON['nodes'][number];
|
||||
|
||||
describe('generateToolRefPinData', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns {} when no refs are passed', async () => {
|
||||
const result = await generateToolRefPinData({
|
||||
|
|
@ -151,7 +152,7 @@ describe('generateToolRefPinData', () => {
|
|||
});
|
||||
|
||||
it('returns {} when the LLM call throws', async () => {
|
||||
const generate = jest.fn().mockRejectedValue(new Error('boom'));
|
||||
const generate = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
mockCreateEvalAgent.mockReturnValue({ generate } as unknown as ReturnType<
|
||||
typeof createEvalAgent
|
||||
>);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { GATEWAY_CONFIRMATION_REQUIRED_PREFIX } from '@n8n/api-types';
|
||||
import type { McpTool, McpToolCallResult } from '@n8n/api-types';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../../__tests__/tool-test-utils';
|
||||
import type { LocalMcpServer } from '../../../types';
|
||||
|
|
@ -81,11 +82,11 @@ const GENERIC_ERROR_RESULT: McpToolCallResult = {
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockServer(tools: McpTool[] = [SAMPLE_TOOL]): jest.Mocked<LocalMcpServer> {
|
||||
function makeMockServer(tools: McpTool[] = [SAMPLE_TOOL]): Mocked<LocalMcpServer> {
|
||||
return {
|
||||
getAvailableTools: jest.fn().mockReturnValue(tools),
|
||||
getToolsByCategory: jest.fn().mockReturnValue([]),
|
||||
callTool: jest.fn(),
|
||||
getAvailableTools: vi.fn().mockReturnValue(tools),
|
||||
getToolsByCategory: vi.fn().mockReturnValue([]),
|
||||
callTool: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -100,10 +101,10 @@ function getExecute(server: LocalMcpServer, toolName = 'write_file') {
|
|||
|
||||
/** Build a ctx object with suspend/resumeData for use in execute calls. */
|
||||
function makeCtx(opts: {
|
||||
suspend?: jest.Mock;
|
||||
suspend?: Mock;
|
||||
resumeData?: Record<string, unknown> | null;
|
||||
}): unknown {
|
||||
return { suspend: opts.suspend ?? jest.fn(), resumeData: opts.resumeData ?? null };
|
||||
return { suspend: opts.suspend ?? vi.fn(), resumeData: opts.resumeData ?? null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -133,7 +134,7 @@ describe('createToolsFromLocalMcpServer', () => {
|
|||
});
|
||||
|
||||
it('skips tools with invalid names', () => {
|
||||
const logger = { warn: jest.fn() };
|
||||
const logger = { warn: vi.fn() };
|
||||
const server = makeMockServer([
|
||||
{ ...SAMPLE_TOOL, name: 'bad tool' },
|
||||
{ ...SAMPLE_TOOL, name: 'read_file' },
|
||||
|
|
@ -153,7 +154,7 @@ describe('createToolsFromLocalMcpServer', () => {
|
|||
});
|
||||
|
||||
it('skips tools with unsafe object key names', () => {
|
||||
const logger = { warn: jest.fn() };
|
||||
const logger = { warn: vi.fn() };
|
||||
const server = makeMockServer([
|
||||
{ ...SAMPLE_TOOL, name: 'constructor' },
|
||||
{ ...SAMPLE_TOOL, name: 'read_file' },
|
||||
|
|
@ -173,7 +174,7 @@ describe('createToolsFromLocalMcpServer', () => {
|
|||
});
|
||||
|
||||
it('skips normalized name collisions between local gateway tools', () => {
|
||||
const logger = { warn: jest.fn() };
|
||||
const logger = { warn: vi.fn() };
|
||||
const server = makeMockServer([
|
||||
{ ...SAMPLE_TOOL, name: 'custom_tool' },
|
||||
{ ...SAMPLE_TOOL, name: 'custom-tool' },
|
||||
|
|
@ -193,7 +194,7 @@ describe('createToolsFromLocalMcpServer', () => {
|
|||
});
|
||||
|
||||
it('skips compatibility-normalized non-ASCII tool names', () => {
|
||||
const logger = { warn: jest.fn() };
|
||||
const logger = { warn: vi.fn() };
|
||||
const server = makeMockServer([
|
||||
{ ...SAMPLE_TOOL, name: '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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -6,23 +6,19 @@ import type {
|
|||
PlannedTaskGraph,
|
||||
PlannedTaskService,
|
||||
} from '../../../types';
|
||||
import type { SetupRequest } from '../../workflows/setup-workflow.schema';
|
||||
import { analyzeWorkflow } from '../../workflows/setup-workflow.service';
|
||||
import { createCompleteCheckpointTool } from '../complete-checkpoint.tool';
|
||||
|
||||
jest.mock('../../workflows/setup-workflow.service', () => ({
|
||||
analyzeWorkflow: jest.fn(),
|
||||
vi.mock('../../workflows/setup-workflow.service', () => ({
|
||||
analyzeWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
const { analyzeWorkflow } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../../workflows/setup-workflow.service') as typeof import('../../workflows/setup-workflow.service');
|
||||
const { createCompleteCheckpointTool } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../complete-checkpoint.tool') as typeof import('../complete-checkpoint.tool');
|
||||
|
||||
function makeService(overrides: Partial<PlannedTaskService> = {}): PlannedTaskService {
|
||||
return {
|
||||
getGraph: jest.fn().mockResolvedValue(null),
|
||||
markCheckpointSucceeded: jest.fn(),
|
||||
markCheckpointFailed: jest.fn(),
|
||||
getGraph: vi.fn().mockResolvedValue(null),
|
||||
markCheckpointSucceeded: vi.fn(),
|
||||
markCheckpointFailed: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as PlannedTaskService;
|
||||
}
|
||||
|
|
@ -39,17 +35,17 @@ function makeContext(
|
|||
modelId: 'model' as OrchestrationContext['modelId'],
|
||||
subAgentMaxSteps: 5,
|
||||
eventBus: {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getEventsAfter: jest.fn(),
|
||||
getNextEventId: jest.fn(),
|
||||
getEventsForRun: jest.fn().mockReturnValue([]),
|
||||
getEventsForRuns: jest.fn().mockReturnValue([]),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
getEventsAfter: vi.fn(),
|
||||
getNextEventId: vi.fn(),
|
||||
getEventsForRun: vi.fn().mockReturnValue([]),
|
||||
getEventsForRuns: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
domainTools: createToolRegistry(),
|
||||
abortSignal: new AbortController().signal,
|
||||
taskStorage: { get: jest.fn(), save: jest.fn() },
|
||||
taskStorage: { get: vi.fn(), save: vi.fn() },
|
||||
plannedTaskService: service,
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -86,12 +82,12 @@ function makeSetupRequiredGraph(): PlannedTaskGraph {
|
|||
|
||||
describe('createCompleteCheckpointTool', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('marks a checkpoint succeeded via markCheckpointSucceeded', async () => {
|
||||
const service = makeService({
|
||||
markCheckpointSucceeded: jest
|
||||
markCheckpointSucceeded: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
|
|
@ -114,16 +110,16 @@ describe('createCompleteCheckpointTool', () => {
|
|||
|
||||
it('does not mark a checkpoint succeeded while dependent workflow setup is pending', async () => {
|
||||
const service = makeService({
|
||||
getGraph: jest.fn().mockResolvedValue(makeSetupRequiredGraph()),
|
||||
markCheckpointSucceeded: jest.fn(),
|
||||
getGraph: vi.fn().mockResolvedValue(makeSetupRequiredGraph()),
|
||||
markCheckpointSucceeded: vi.fn(),
|
||||
});
|
||||
(analyzeWorkflow as jest.Mock).mockResolvedValue([
|
||||
vi.mocked(analyzeWorkflow).mockResolvedValue([
|
||||
{
|
||||
node: { name: 'Slack' },
|
||||
node: { name: 'Slack' } as SetupRequest['node'],
|
||||
credentialType: 'slackApi',
|
||||
needsAction: true,
|
||||
},
|
||||
]);
|
||||
] as SetupRequest[]);
|
||||
const tool = createCompleteCheckpointTool(
|
||||
makeContext(service, { domainContext: {} as OrchestrationContext['domainContext'] }),
|
||||
);
|
||||
|
|
@ -144,12 +140,12 @@ describe('createCompleteCheckpointTool', () => {
|
|||
|
||||
it('marks a setup-required checkpoint succeeded after setup has no pending action', async () => {
|
||||
const service = makeService({
|
||||
getGraph: jest.fn().mockResolvedValue(makeSetupRequiredGraph()),
|
||||
markCheckpointSucceeded: jest
|
||||
getGraph: vi.fn().mockResolvedValue(makeSetupRequiredGraph()),
|
||||
markCheckpointSucceeded: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
(analyzeWorkflow as jest.Mock).mockResolvedValue([]);
|
||||
vi.mocked(analyzeWorkflow).mockResolvedValue([]);
|
||||
const tool = createCompleteCheckpointTool(
|
||||
makeContext(service, { domainContext: {} as OrchestrationContext['domainContext'] }),
|
||||
);
|
||||
|
|
@ -169,7 +165,7 @@ describe('createCompleteCheckpointTool', () => {
|
|||
|
||||
it('marks a checkpoint failed via markCheckpointFailed', async () => {
|
||||
const service = makeService({
|
||||
markCheckpointFailed: jest
|
||||
markCheckpointFailed: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
|
|
@ -190,7 +186,7 @@ describe('createCompleteCheckpointTool', () => {
|
|||
|
||||
it('forwards structured outcome to markCheckpointFailed so replans keep execution context', async () => {
|
||||
const service = makeService({
|
||||
markCheckpointFailed: jest
|
||||
markCheckpointFailed: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
|
|
@ -220,7 +216,7 @@ describe('createCompleteCheckpointTool', () => {
|
|||
it('returns error string (not throw) on not-found', async () => {
|
||||
const not: CheckpointSettleResult = { ok: false, reason: 'not-found' };
|
||||
const service = makeService({
|
||||
markCheckpointSucceeded: jest.fn().mockResolvedValue(not),
|
||||
markCheckpointSucceeded: vi.fn().mockResolvedValue(not),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service));
|
||||
|
||||
|
|
@ -237,7 +233,7 @@ describe('createCompleteCheckpointTool', () => {
|
|||
actual: { kind: 'build-workflow' },
|
||||
};
|
||||
const service = makeService({
|
||||
markCheckpointSucceeded: jest.fn().mockResolvedValue(wk),
|
||||
markCheckpointSucceeded: vi.fn().mockResolvedValue(wk),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service));
|
||||
|
||||
|
|
@ -255,7 +251,7 @@ describe('createCompleteCheckpointTool', () => {
|
|||
actual: { status: 'planned' },
|
||||
};
|
||||
const service = makeService({
|
||||
markCheckpointSucceeded: jest.fn().mockResolvedValue(ws),
|
||||
markCheckpointSucceeded: vi.fn().mockResolvedValue(ws),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service));
|
||||
|
||||
|
|
@ -280,7 +276,7 @@ describe('createCompleteCheckpointTool', () => {
|
|||
|
||||
it('defaults failed-error to result or a sensible default', async () => {
|
||||
const service = makeService({
|
||||
markCheckpointFailed: jest
|
||||
markCheckpointFailed: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
/* eslint-disable import-x/order */
|
||||
import { executeTool } from '../../../__tests__/tool-test-utils';
|
||||
import { createToolRegistry } from '../../../tool-registry';
|
||||
import type { OrchestrationContext, TaskStorage } from '../../../types';
|
||||
import { delegateInputSchema } from '../delegate.schemas';
|
||||
|
||||
jest.mock('../../../stream/consume-with-hitl', () => ({ consumeStreamWithHitl: jest.fn() }));
|
||||
jest.mock('../../../storage/iteration-log', () => ({ formatPreviousAttempts: jest.fn() }));
|
||||
vi.mock('../../../stream/consume-with-hitl', () => ({ consumeStreamWithHitl: vi.fn() }));
|
||||
vi.mock('../../../storage/iteration-log', () => ({ formatPreviousAttempts: vi.fn() }));
|
||||
|
||||
const { createDelegateTool } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../delegate.tool') as typeof import('../delegate.tool');
|
||||
import { createDelegateTool } from '../delegate.tool';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -23,14 +22,14 @@ function createMockContext(domainTools: Record<string, unknown> = {}): Orchestra
|
|||
modelId: 'test-model',
|
||||
subAgentMaxSteps: 5,
|
||||
eventBus: {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getEventsAfter: jest.fn(),
|
||||
getNextEventId: jest.fn(),
|
||||
getEventsForRun: jest.fn().mockReturnValue([]),
|
||||
getEventsForRuns: jest.fn().mockReturnValue([]),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
getEventsAfter: vi.fn(),
|
||||
getNextEventId: vi.fn(),
|
||||
getEventsForRun: vi.fn().mockReturnValue([]),
|
||||
getEventsForRuns: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
domainTools: createToolRegistry(
|
||||
Object.entries(domainTools).map(([name, tool]) => [
|
||||
name,
|
||||
|
|
@ -39,8 +38,8 @@ function createMockContext(domainTools: Record<string, unknown> = {}): Orchestra
|
|||
),
|
||||
abortSignal: new AbortController().signal,
|
||||
taskStorage: {
|
||||
get: jest.fn(),
|
||||
save: jest.fn(),
|
||||
get: vi.fn(),
|
||||
save: vi.fn(),
|
||||
} as TaskStorage,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { BuiltTool } from '@n8n/agents';
|
||||
import { DEFAULT_INSTANCE_AI_PERMISSIONS } from '@n8n/api-types';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { createToolRegistry } from '../../../tool-registry';
|
||||
import type { InstanceAiContext, OrchestrationContext } from '../../../types';
|
||||
|
|
@ -17,11 +17,11 @@ function makeContext(
|
|||
): OrchestrationContext {
|
||||
const domainContext = mock<InstanceAiContext>();
|
||||
domainContext.permissions = { ...DEFAULT_INSTANCE_AI_PERMISSIONS, updateWorkflow };
|
||||
domainContext.workflowService.get = jest
|
||||
domainContext.workflowService.get = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'w1', name: 'Test', nodes: [], connections: {}, active: false });
|
||||
domainContext.workflowService.updateFromWorkflowJSON = jest.fn().mockResolvedValue(undefined);
|
||||
domainContext.workflowService.updateVersion = jest.fn().mockResolvedValue(undefined);
|
||||
domainContext.workflowService.updateFromWorkflowJSON = vi.fn().mockResolvedValue(undefined);
|
||||
domainContext.workflowService.updateVersion = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const ctx = mock<OrchestrationContext>();
|
||||
ctx.domainTools = createToolRegistry([
|
||||
|
|
@ -33,7 +33,7 @@ function makeContext(
|
|||
ctx.runId = 'run-1';
|
||||
ctx.orchestratorAgentId = 'root-agent';
|
||||
ctx.modelId = 'test-model' as OrchestrationContext['modelId'];
|
||||
ctx.eventBus = { publish: jest.fn() } as unknown as OrchestrationContext['eventBus'];
|
||||
ctx.eventBus = { publish: vi.fn() } as unknown as OrchestrationContext['eventBus'];
|
||||
ctx.tracing = undefined;
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ describe('buildEvalSetupTools', () => {
|
|||
it('allows saving workflow JSON without prompting when parent permission is require_approval', async () => {
|
||||
const ctx = makeContext('require_approval');
|
||||
const tools = buildEvalSetupTools(ctx);
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
const workflows = tools.get('workflows');
|
||||
const workflow = { name: 'Eval setup', nodes: [], connections: {} };
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ describe('buildEvalSetupTools', () => {
|
|||
describe('startEvalSetupAgentTask', () => {
|
||||
it('deduplicates eval setup background tasks by workflow id', () => {
|
||||
const ctx = makeContext('require_approval');
|
||||
ctx.spawnBackgroundTask = jest.fn().mockReturnValue({
|
||||
ctx.spawnBackgroundTask = vi.fn().mockReturnValue({
|
||||
status: 'started',
|
||||
taskId: 'task-1',
|
||||
agentId: 'agent-1',
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../../__tests__/tool-test-utils';
|
||||
import { PlanValidationError } from '../../../planned-tasks/planned-task-service';
|
||||
import { createToolRegistry } from '../../../tool-registry';
|
||||
import type { OrchestrationContext, PlannedTaskService, TaskStorage } from '../../../types';
|
||||
|
||||
const { createPlanTool } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../plan.tool') as typeof import('../plan.tool');
|
||||
import { createPlanTool } from '../plan.tool';
|
||||
|
||||
function makePlannedTaskService(overrides: Partial<PlannedTaskService> = {}): PlannedTaskService {
|
||||
return {
|
||||
createPlan: jest.fn().mockResolvedValue(undefined),
|
||||
getGraph: jest.fn().mockResolvedValue(null),
|
||||
approvePlan: jest.fn().mockResolvedValue(undefined),
|
||||
denyPlan: jest.fn().mockResolvedValue(undefined),
|
||||
clear: jest.fn().mockResolvedValue(undefined),
|
||||
createPlan: vi.fn().mockResolvedValue(undefined),
|
||||
getGraph: vi.fn().mockResolvedValue(null),
|
||||
approvePlan: vi.fn().mockResolvedValue(undefined),
|
||||
denyPlan: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as PlannedTaskService;
|
||||
}
|
||||
|
|
@ -27,22 +26,22 @@ function createMockContext(overrides: Partial<OrchestrationContext> = {}): Orche
|
|||
modelId: 'test-model' as OrchestrationContext['modelId'],
|
||||
subAgentMaxSteps: 5,
|
||||
eventBus: {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getEventsAfter: jest.fn(),
|
||||
getNextEventId: jest.fn(),
|
||||
getEventsForRun: jest.fn().mockReturnValue([]),
|
||||
getEventsForRuns: jest.fn().mockReturnValue([]),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
getEventsAfter: vi.fn(),
|
||||
getNextEventId: vi.fn(),
|
||||
getEventsForRun: vi.fn().mockReturnValue([]),
|
||||
getEventsForRuns: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
domainTools: createToolRegistry(),
|
||||
abortSignal: new AbortController().signal,
|
||||
taskStorage: {
|
||||
get: jest.fn(),
|
||||
save: jest.fn(),
|
||||
get: vi.fn(),
|
||||
save: vi.fn(),
|
||||
} as TaskStorage,
|
||||
plannedTaskService: makePlannedTaskService(),
|
||||
schedulePlannedTasks: jest.fn().mockResolvedValue(undefined),
|
||||
schedulePlannedTasks: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -93,7 +92,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
currentUserMessage: 'Create a data table for users',
|
||||
});
|
||||
const tool = createPlanTool(context);
|
||||
const suspend = jest.fn().mockResolvedValue(undefined);
|
||||
const suspend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const out = await executeTool(
|
||||
tool,
|
||||
|
|
@ -107,7 +106,9 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
|
||||
// Reaches native suspend path.
|
||||
expect(out).toBeUndefined();
|
||||
const warnMock = context.logger.warn as jest.Mock<void, [string, Record<string, unknown>?]>;
|
||||
const warnMock = context.logger.warn as Mock<
|
||||
(...args: [string, Record<string, unknown>?]) => void
|
||||
>;
|
||||
const bypassCall = warnMock.mock.calls.find(
|
||||
(call) => call[0] === 'create-tasks bypassing planner with skipPlannerDiscovery=true',
|
||||
);
|
||||
|
|
@ -131,7 +132,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
const context = createMockContext({
|
||||
currentUserMessage: 'revise the plan',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
getGraph: jest.fn().mockResolvedValue({
|
||||
getGraph: vi.fn().mockResolvedValue({
|
||||
threadId: 'test-thread',
|
||||
status: 'active',
|
||||
tasks: [],
|
||||
|
|
@ -139,7 +140,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
}),
|
||||
});
|
||||
const tool = createPlanTool(context);
|
||||
const suspend = jest.fn().mockResolvedValue(undefined);
|
||||
const suspend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend });
|
||||
|
||||
|
|
@ -154,7 +155,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
const context = createMockContext({
|
||||
currentUserMessage: 'Build me a new, unrelated workflow',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
getGraph: jest.fn().mockResolvedValue({
|
||||
getGraph: vi.fn().mockResolvedValue({
|
||||
threadId: 'test-thread',
|
||||
status: 'completed',
|
||||
tasks: [],
|
||||
|
|
@ -163,7 +164,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
});
|
||||
const tool = createPlanTool(context);
|
||||
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: jest.fn() });
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: vi.fn() });
|
||||
|
||||
expect(out.result).toMatch(/^Error: `create-tasks` is for replanning only/);
|
||||
expect(context.plannedTaskService!.createPlan).not.toHaveBeenCalled();
|
||||
|
|
@ -175,7 +176,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
isReplanFollowUp: true,
|
||||
});
|
||||
const tool = createPlanTool(context);
|
||||
const suspend = jest.fn().mockResolvedValue(undefined);
|
||||
const suspend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend });
|
||||
|
||||
|
|
@ -203,7 +204,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
process.env.N8N_INSTANCE_AI_ENFORCE_CREATE_TASKS_REPLAN = 'false';
|
||||
const context = createMockContext({ currentUserMessage: 'ordinary initial request' });
|
||||
const tool = createPlanTool(context);
|
||||
const suspend = jest.fn().mockResolvedValue(undefined);
|
||||
const suspend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend });
|
||||
|
||||
|
|
@ -244,8 +245,8 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
const context = createMockContext({
|
||||
currentUserMessage: 'ordinary message',
|
||||
taskStorage: {
|
||||
get: jest.fn(),
|
||||
save: jest.fn().mockRejectedValue(new Error('storage flake')),
|
||||
get: vi.fn(),
|
||||
save: vi.fn().mockRejectedValue(new Error('storage flake')),
|
||||
} as TaskStorage,
|
||||
});
|
||||
const tool = createPlanTool(context);
|
||||
|
|
@ -322,7 +323,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
currentUserMessage: 'revise the plan',
|
||||
runId: 'run-1',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
getGraph: jest.fn().mockResolvedValue({
|
||||
getGraph: vi.fn().mockResolvedValue({
|
||||
threadId: 'test-thread',
|
||||
status: 'awaiting_approval',
|
||||
planRunId: 'run-1',
|
||||
|
|
@ -331,7 +332,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
}),
|
||||
});
|
||||
const tool = createPlanTool(context);
|
||||
const suspend = jest.fn().mockResolvedValue(undefined);
|
||||
const suspend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend });
|
||||
|
||||
|
|
@ -344,7 +345,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
currentUserMessage: 'try again',
|
||||
messageGroupId: 'mg-1',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
getGraph: jest.fn().mockResolvedValue({
|
||||
getGraph: vi.fn().mockResolvedValue({
|
||||
threadId: 'test-thread',
|
||||
status: 'cancelled',
|
||||
planRunId: 'run-prev',
|
||||
|
|
@ -355,7 +356,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
});
|
||||
const tool = createPlanTool(context);
|
||||
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: jest.fn() });
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: vi.fn() });
|
||||
|
||||
expect(out.taskCount).toBe(0);
|
||||
expect(out.result).toMatch(/denied a plan earlier in this turn/i);
|
||||
|
|
@ -367,7 +368,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
currentUserMessage: 'new user message',
|
||||
messageGroupId: 'mg-2',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
getGraph: jest.fn().mockResolvedValue({
|
||||
getGraph: vi.fn().mockResolvedValue({
|
||||
threadId: 'test-thread',
|
||||
status: 'cancelled',
|
||||
planRunId: 'run-prev',
|
||||
|
|
@ -378,7 +379,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
isReplanFollowUp: true,
|
||||
});
|
||||
const tool = createPlanTool(context);
|
||||
const suspend = jest.fn().mockResolvedValue(undefined);
|
||||
const suspend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await executeTool(tool, { tasks: validTasks() }, { suspend });
|
||||
|
||||
|
|
@ -393,7 +394,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
currentUserMessage: 'unrelated new request',
|
||||
runId: 'run-2',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
getGraph: jest.fn().mockResolvedValue({
|
||||
getGraph: vi.fn().mockResolvedValue({
|
||||
threadId: 'test-thread',
|
||||
status: 'awaiting_approval',
|
||||
planRunId: 'run-1',
|
||||
|
|
@ -403,7 +404,7 @@ describe('createPlanTool — replan-only guard', () => {
|
|||
});
|
||||
const tool = createPlanTool(context);
|
||||
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: jest.fn() });
|
||||
const out = await executeTool(tool, { tasks: validTasks() }, { suspend: vi.fn() });
|
||||
|
||||
expect(out.result).toMatch(/^Error: `create-tasks` is for replanning only/);
|
||||
expect(context.plannedTaskService!.createPlan).not.toHaveBeenCalled();
|
||||
|
|
@ -418,11 +419,11 @@ describe('createPlanTool — createPlan validation failures', () => {
|
|||
const context = createMockContext({
|
||||
currentUserMessage: 'replan after failure',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
createPlan: jest.fn().mockRejectedValue(validatorError),
|
||||
createPlan: vi.fn().mockRejectedValue(validatorError),
|
||||
}),
|
||||
});
|
||||
const tool = createPlanTool(context);
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
|
||||
const out = await executeTool(
|
||||
tool,
|
||||
|
|
@ -449,7 +450,7 @@ describe('createPlanTool — createPlan validation failures', () => {
|
|||
const context = createMockContext({
|
||||
currentUserMessage: 'replan',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
createPlan: jest.fn().mockRejectedValue(storageError),
|
||||
createPlan: vi.fn().mockRejectedValue(storageError),
|
||||
}),
|
||||
});
|
||||
const tool = createPlanTool(context);
|
||||
|
|
@ -459,7 +460,7 @@ describe('createPlanTool — createPlan validation failures', () => {
|
|||
tool,
|
||||
{ tasks: validTasks(), skipPlannerDiscovery: true, reason: 'bypass' },
|
||||
{
|
||||
suspend: jest.fn(),
|
||||
suspend: vi.fn(),
|
||||
},
|
||||
),
|
||||
).rejects.toBe(storageError);
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ import type { OrchestrationContext, TaskStorage } from '../../../types';
|
|||
import type { WorkflowLoopAction } from '../../../workflow-loop/workflow-loop-state';
|
||||
import { createReportVerificationVerdictTool } from '../report-verification-verdict.tool';
|
||||
|
||||
function createWorkflowTaskService(reportVerificationVerdict = jest.fn()) {
|
||||
function createWorkflowTaskService(reportVerificationVerdict = vi.fn()) {
|
||||
return {
|
||||
reportBuildOutcome: jest.fn(),
|
||||
reportBuildOutcome: vi.fn(),
|
||||
reportVerificationVerdict,
|
||||
getBuildOutcome: jest.fn(),
|
||||
getWorkflowLoopState: jest.fn(),
|
||||
updateBuildOutcome: jest.fn(),
|
||||
getBuildOutcome: vi.fn(),
|
||||
getWorkflowLoopState: vi.fn(),
|
||||
updateBuildOutcome: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createReportVerificationVerdictMock(action: WorkflowLoopAction) {
|
||||
const reportVerificationVerdict = jest.fn<Promise<WorkflowLoopAction>, [unknown]>();
|
||||
const reportVerificationVerdict = vi.fn<(...args: [unknown]) => Promise<WorkflowLoopAction>>();
|
||||
reportVerificationVerdict.mockResolvedValue(action);
|
||||
return reportVerificationVerdict;
|
||||
}
|
||||
|
|
@ -29,19 +29,19 @@ function createMockContext(overrides: Partial<OrchestrationContext> = {}): Orche
|
|||
modelId: 'test-model',
|
||||
subAgentMaxSteps: 5,
|
||||
eventBus: {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getEventsAfter: jest.fn(),
|
||||
getNextEventId: jest.fn(),
|
||||
getEventsForRun: jest.fn().mockReturnValue([]),
|
||||
getEventsForRuns: jest.fn().mockReturnValue([]),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
getEventsAfter: vi.fn(),
|
||||
getNextEventId: vi.fn(),
|
||||
getEventsForRun: vi.fn().mockReturnValue([]),
|
||||
getEventsForRuns: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
domainTools: createToolRegistry(),
|
||||
abortSignal: new AbortController().signal,
|
||||
taskStorage: {
|
||||
get: jest.fn(),
|
||||
save: jest.fn(),
|
||||
get: vi.fn(),
|
||||
save: vi.fn(),
|
||||
} as TaskStorage,
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -70,7 +70,7 @@ describe('report-verification-verdict tool', () => {
|
|||
workflowId: 'wf-123',
|
||||
summary: 'All good',
|
||||
};
|
||||
const reportVerificationVerdict = jest.fn().mockResolvedValue(doneAction);
|
||||
const reportVerificationVerdict = vi.fn().mockResolvedValue(doneAction);
|
||||
const context = createMockContext({
|
||||
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
|
||||
});
|
||||
|
|
@ -91,7 +91,7 @@ describe('report-verification-verdict tool', () => {
|
|||
|
||||
it('returns verify guidance when action is verify', async () => {
|
||||
const verifyAction: WorkflowLoopAction = { type: 'verify', workflowId: 'wf-123' };
|
||||
const reportVerificationVerdict = jest.fn().mockResolvedValue(verifyAction);
|
||||
const reportVerificationVerdict = vi.fn().mockResolvedValue(verifyAction);
|
||||
const context = createMockContext({
|
||||
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
|
||||
});
|
||||
|
|
@ -182,7 +182,7 @@ describe('report-verification-verdict tool', () => {
|
|||
});
|
||||
|
||||
it('refuses repair verdict when persisted remediation is terminal', async () => {
|
||||
const reportVerificationVerdict = jest.fn();
|
||||
const reportVerificationVerdict = vi.fn();
|
||||
const workflowTaskService = createWorkflowTaskService(reportVerificationVerdict);
|
||||
workflowTaskService.getWorkflowLoopState.mockResolvedValue({
|
||||
workItemId: 'wi_test1234',
|
||||
|
|
@ -262,7 +262,7 @@ describe('report-verification-verdict tool', () => {
|
|||
});
|
||||
|
||||
it('converts non-editable remediation into a terminal verdict', async () => {
|
||||
const reportVerificationVerdict = jest.fn().mockResolvedValue({
|
||||
const reportVerificationVerdict = vi.fn().mockResolvedValue({
|
||||
type: 'blocked',
|
||||
reason: 'Route to setup.',
|
||||
} satisfies WorkflowLoopAction);
|
||||
|
|
@ -302,7 +302,7 @@ describe('report-verification-verdict tool', () => {
|
|||
workflowId: 'wf-123',
|
||||
failureDetails: 'Missing connection between nodes',
|
||||
};
|
||||
const reportVerificationVerdict = jest.fn().mockResolvedValue(rebuildAction);
|
||||
const reportVerificationVerdict = vi.fn().mockResolvedValue(rebuildAction);
|
||||
const context = createMockContext({
|
||||
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
|
||||
});
|
||||
|
|
@ -325,7 +325,7 @@ describe('report-verification-verdict tool', () => {
|
|||
type: 'blocked',
|
||||
reason: 'Repeated patch failure: TypeError',
|
||||
};
|
||||
const reportVerificationVerdict = jest.fn().mockResolvedValue(blockedAction);
|
||||
const reportVerificationVerdict = vi.fn().mockResolvedValue(blockedAction);
|
||||
const context = createMockContext({
|
||||
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
|
||||
});
|
||||
|
|
@ -347,7 +347,7 @@ describe('report-verification-verdict tool', () => {
|
|||
workflowId: 'wf-123',
|
||||
summary: 'OK',
|
||||
};
|
||||
const reportVerificationVerdict = jest.fn().mockResolvedValue(doneAction);
|
||||
const reportVerificationVerdict = vi.fn().mockResolvedValue(doneAction);
|
||||
const context = createMockContext({
|
||||
workflowTaskService: createWorkflowTaskService(reportVerificationVerdict),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
/* eslint-disable import-x/order */
|
||||
import type { OrchestrationContext } from '../../../types';
|
||||
|
||||
const mockCreateDetachedSubAgentTraceContext = jest.fn<Promise<unknown>, [unknown]>();
|
||||
const mockCreateDetachedSubAgentTraceContext = vi.fn<(arg: unknown) => Promise<unknown>>();
|
||||
|
||||
jest.mock('../../../tracing/langsmith-tracing', () => ({
|
||||
vi.mock('../../../tracing/langsmith-tracing', () => ({
|
||||
createDetachedSubAgentTraceContext: async (options: unknown): Promise<unknown> =>
|
||||
await mockCreateDetachedSubAgentTraceContext(options),
|
||||
getCurrentOtelSpanContext: jest.fn(() => undefined),
|
||||
getCurrentTraceToolCallId: jest.fn(() => undefined),
|
||||
mergeCurrentTraceMetadata: jest.fn(),
|
||||
getCurrentOtelSpanContext: vi.fn(() => undefined),
|
||||
getCurrentTraceToolCallId: vi.fn(() => undefined),
|
||||
mergeCurrentTraceMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
const { createDetachedSubAgentTraceFactory } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../tracing-utils') as typeof import('../tracing-utils');
|
||||
import { createDetachedSubAgentTraceFactory } from '../tracing-utils';
|
||||
|
||||
describe('createDetachedSubAgentTraceFactory', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('propagates parent version metadata to detached sub-agent traces', async () => {
|
||||
|
|
@ -30,7 +29,7 @@ describe('createDetachedSubAgentTraceFactory', () => {
|
|||
userId: 'user-1',
|
||||
modelId: 'model-1',
|
||||
orchestratorAgentId: 'orchestrator-1',
|
||||
tracingProxyConfig: { getAuthHeaders: jest.fn() },
|
||||
tracingProxyConfig: { getAuthHeaders: vi.fn() },
|
||||
tracing: {
|
||||
projectName: 'project-1',
|
||||
rootRun: { traceId: 'root-trace' },
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../../__tests__/tool-test-utils';
|
||||
import { createToolRegistry } from '../../../tool-registry';
|
||||
import type {
|
||||
|
|
@ -28,9 +30,9 @@ type VerifyBuiltWorkflowOutput = {
|
|||
|
||||
function createContext(overrides: Partial<OrchestrationContext> = {}): OrchestrationContext {
|
||||
const workflowTaskService = {
|
||||
reportBuildOutcome: jest.fn(),
|
||||
reportVerificationVerdict: jest.fn(),
|
||||
getBuildOutcome: jest.fn().mockResolvedValue({
|
||||
reportBuildOutcome: vi.fn(),
|
||||
reportVerificationVerdict: vi.fn(),
|
||||
getBuildOutcome: vi.fn().mockResolvedValue({
|
||||
workItemId: 'wi_1',
|
||||
taskId: 'task_1',
|
||||
workflowId: 'wf_1',
|
||||
|
|
@ -39,8 +41,8 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
|
|||
needsUserInput: false,
|
||||
summary: 'Built',
|
||||
}),
|
||||
getWorkflowLoopState: jest.fn(),
|
||||
updateBuildOutcome: jest.fn(),
|
||||
getWorkflowLoopState: vi.fn(),
|
||||
updateBuildOutcome: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -52,10 +54,10 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
|
|||
subAgentMaxSteps: 5,
|
||||
eventBus: {} as OrchestrationContext['eventBus'],
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as OrchestrationContext['logger'],
|
||||
domainTools: createToolRegistry(),
|
||||
abortSignal: new AbortController().signal,
|
||||
|
|
@ -64,10 +66,10 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
|
|||
domainContext: {
|
||||
userId: 'user_1',
|
||||
workflowService: {
|
||||
getAsWorkflowJSON: jest.fn().mockResolvedValue({ nodes: [] }),
|
||||
getAsWorkflowJSON: vi.fn().mockResolvedValue({ nodes: [] }),
|
||||
} as unknown as InstanceAiWorkflowService,
|
||||
executionService: {
|
||||
run: jest.fn().mockResolvedValue({
|
||||
run: vi.fn().mockResolvedValue({
|
||||
executionId: 'exec_1',
|
||||
status: 'success',
|
||||
}),
|
||||
|
|
@ -75,8 +77,8 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
|
|||
credentialService: {} as never,
|
||||
nodeService: {} as never,
|
||||
dataTableService: {
|
||||
queryRows: jest.fn().mockResolvedValue({ count: 0, data: [] }),
|
||||
deleteRows: jest.fn().mockResolvedValue({ deletedCount: 0 }),
|
||||
queryRows: vi.fn().mockResolvedValue({ count: 0, data: [] }),
|
||||
deleteRows: vi.fn().mockResolvedValue({ deletedCount: 0 }),
|
||||
} as unknown as InstanceAiDataTableService,
|
||||
},
|
||||
...overrides,
|
||||
|
|
@ -86,7 +88,7 @@ function createContext(overrides: Partial<OrchestrationContext> = {}): Orchestra
|
|||
describe('verify-built-workflow tool — remediation guard', () => {
|
||||
it('routes mocked-credential verification failures to setup and records terminal verdict', async () => {
|
||||
const context = createContext();
|
||||
jest.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
|
||||
vi.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
|
||||
workItemId: 'wi_1',
|
||||
taskId: 'task_1',
|
||||
workflowId: 'wf_1',
|
||||
|
|
@ -97,7 +99,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
mockedNodeNames: ['Gmail'],
|
||||
summary: 'Built',
|
||||
});
|
||||
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
executionId: 'exec_1',
|
||||
status: 'error',
|
||||
error: 'Gmail credentials are mocked',
|
||||
|
|
@ -116,14 +118,14 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
verdict: 'needs_user_input',
|
||||
}),
|
||||
);
|
||||
const reported = jest.mocked(context.workflowTaskService!.reportVerificationVerdict).mock
|
||||
const reported = vi.mocked(context.workflowTaskService!.reportVerificationVerdict).mock
|
||||
.calls[0]?.[0] as { remediation?: { category?: string } };
|
||||
expect(reported.remediation).toMatchObject({ category: 'needs_setup' });
|
||||
});
|
||||
|
||||
it('does not treat mocked credentials as setup when the execution error is code-fixable', async () => {
|
||||
const context = createContext();
|
||||
jest.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
|
||||
vi.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
|
||||
workItemId: 'wi_1',
|
||||
taskId: 'task_1',
|
||||
workflowId: 'wf_1',
|
||||
|
|
@ -134,7 +136,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
mockedNodeNames: ['Slack'],
|
||||
summary: 'Built',
|
||||
});
|
||||
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
executionId: 'exec_1',
|
||||
status: 'error',
|
||||
error: 'Code node failed: Cannot read properties of undefined',
|
||||
|
|
@ -152,14 +154,14 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
});
|
||||
|
||||
it('returns terminal remediation even when verdict persistence and telemetry fail', async () => {
|
||||
const trackTelemetry = jest.fn(() => {
|
||||
const trackTelemetry = vi.fn(() => {
|
||||
throw new Error('telemetry unavailable');
|
||||
});
|
||||
const context = createContext({ trackTelemetry });
|
||||
jest
|
||||
.mocked(context.workflowTaskService!.reportVerificationVerdict)
|
||||
.mockRejectedValue(new Error('storage unavailable'));
|
||||
jest.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
|
||||
vi.mocked(context.workflowTaskService!.reportVerificationVerdict).mockRejectedValue(
|
||||
new Error('storage unavailable'),
|
||||
);
|
||||
vi.mocked(context.workflowTaskService!.getBuildOutcome).mockResolvedValue({
|
||||
workItemId: 'wi_1',
|
||||
taskId: 'task_1',
|
||||
workflowId: 'wf_1',
|
||||
|
|
@ -170,7 +172,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
mockedNodeNames: ['Gmail'],
|
||||
summary: 'Built',
|
||||
});
|
||||
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
executionId: 'exec_1',
|
||||
status: 'error',
|
||||
error: 'Gmail credentials are mocked',
|
||||
|
|
@ -199,7 +201,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
|
||||
it('does not execute or report another verdict when the persisted guard is terminal', async () => {
|
||||
const context = createContext();
|
||||
jest.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
|
||||
vi.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
|
||||
workItemId: 'wi_1',
|
||||
threadId: 'thread_1',
|
||||
runId: 'run_1',
|
||||
|
|
@ -232,7 +234,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
|
||||
it('ignores terminal remediation from a previous run', async () => {
|
||||
const context = createContext();
|
||||
jest.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
|
||||
vi.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
|
||||
workItemId: 'wi_1',
|
||||
threadId: 'thread_1',
|
||||
runId: 'run_previous',
|
||||
|
|
@ -258,7 +260,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
|
||||
it('still verifies the second allowed post-submit repair before blocking further edits', async () => {
|
||||
const context = createContext();
|
||||
jest.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
|
||||
vi.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
|
||||
workItemId: 'wi_1',
|
||||
threadId: 'thread_1',
|
||||
runId: 'run_1',
|
||||
|
|
@ -287,7 +289,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
|
||||
it('blocks a failing verification after the second post-submit repair was already submitted', async () => {
|
||||
const context = createContext();
|
||||
jest.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
|
||||
vi.mocked(context.workflowTaskService!.getWorkflowLoopState).mockResolvedValue({
|
||||
workItemId: 'wi_1',
|
||||
threadId: 'thread_1',
|
||||
runId: 'run_1',
|
||||
|
|
@ -305,7 +307,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
guidance: 'Verify the latest repair.',
|
||||
}),
|
||||
});
|
||||
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
executionId: 'exec_1',
|
||||
status: 'error',
|
||||
error: 'Code node still fails',
|
||||
|
|
@ -332,7 +334,7 @@ describe('verify-built-workflow tool — remediation guard', () => {
|
|||
|
||||
it('returns editable remediation for generic runtime failures without terminal reporting', async () => {
|
||||
const context = createContext();
|
||||
jest.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
vi.mocked(context.domainContext!.executionService.run).mockResolvedValue({
|
||||
executionId: 'exec_1',
|
||||
status: 'error',
|
||||
error: 'Node parameter value is invalid',
|
||||
|
|
@ -360,15 +362,20 @@ interface VerifyToolContext {
|
|||
workflowTaskService: WorkflowTaskService;
|
||||
domainContext: {
|
||||
executionService: {
|
||||
run: jest.Mock<
|
||||
Promise<ExecutionRunResult>,
|
||||
[string, Record<string, unknown> | undefined, { timeout?: number; pinData?: unknown }]
|
||||
run: Mock<
|
||||
(
|
||||
...args: [
|
||||
string,
|
||||
Record<string, unknown> | undefined,
|
||||
{ timeout?: number; pinData?: unknown },
|
||||
]
|
||||
) => Promise<ExecutionRunResult>
|
||||
>;
|
||||
};
|
||||
workflowService?: InstanceAiWorkflowService;
|
||||
dataTableService?: InstanceAiDataTableService;
|
||||
};
|
||||
logger: { debug: jest.Mock; info: jest.Mock; warn: jest.Mock; error: jest.Mock };
|
||||
logger: { debug: Mock; info: Mock; warn: Mock; error: Mock };
|
||||
}
|
||||
|
||||
function makeBuildOutcome(overrides: Partial<WorkflowBuildOutcome> = {}): WorkflowBuildOutcome {
|
||||
|
|
@ -399,12 +406,12 @@ function makeContext(
|
|||
snapshotErrors?: Record<string, Error>;
|
||||
} = {},
|
||||
) {
|
||||
const updateBuildOutcome = jest.fn(
|
||||
const updateBuildOutcome = vi.fn(
|
||||
async (_workItemId: string, _update: Partial<WorkflowBuildOutcome>) => {
|
||||
await Promise.resolve();
|
||||
},
|
||||
);
|
||||
const run = jest.fn(
|
||||
const run = vi.fn(
|
||||
async (
|
||||
_workflowId: string,
|
||||
_inputData: Record<string, unknown> | undefined,
|
||||
|
|
@ -421,7 +428,7 @@ function makeContext(
|
|||
* after the snapshot phase for a given table switches to `queriesAfterRun`.
|
||||
*/
|
||||
const snapshotDone = new Set<string>();
|
||||
const queryRows = jest.fn(
|
||||
const queryRows = vi.fn(
|
||||
async (
|
||||
dataTableId: string,
|
||||
opts?: { limit?: number; offset?: number },
|
||||
|
|
@ -455,13 +462,13 @@ function makeContext(
|
|||
value: string | number | boolean | null;
|
||||
}>;
|
||||
};
|
||||
const deleteRows = jest.fn(async (_dataTableId: string, _filter: DeleteRowsFilter) => {
|
||||
const deleteRows = vi.fn(async (_dataTableId: string, _filter: DeleteRowsFilter) => {
|
||||
await Promise.resolve();
|
||||
return { deletedCount: 0, dataTableId: '', tableName: '', projectId: '' };
|
||||
});
|
||||
|
||||
const workflowService = {
|
||||
getAsWorkflowJSON: jest.fn(async () => {
|
||||
getAsWorkflowJSON: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
return { nodes: overrides.workflowNodes ?? [] };
|
||||
}),
|
||||
|
|
@ -473,21 +480,21 @@ function makeContext(
|
|||
} as unknown as InstanceAiDataTableService;
|
||||
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const ctx: VerifyToolContext = {
|
||||
workflowTaskService: {
|
||||
reportBuildOutcome: jest.fn(),
|
||||
reportVerificationVerdict: jest.fn(),
|
||||
getBuildOutcome: jest.fn(async () => {
|
||||
reportBuildOutcome: vi.fn(),
|
||||
reportVerificationVerdict: vi.fn(),
|
||||
getBuildOutcome: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
return outcome;
|
||||
}),
|
||||
getWorkflowLoopState: jest.fn(),
|
||||
getWorkflowLoopState: vi.fn(),
|
||||
updateBuildOutcome,
|
||||
} as unknown as WorkflowTaskService,
|
||||
domainContext: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { resolveCredentials } from '../resolve-credentials';
|
|||
import { stripStaleCredentialsFromWorkflow } from '../setup-workflow.service';
|
||||
import { ensureWebhookIds } from '../submit-workflow.tool';
|
||||
|
||||
jest.mock('../../../workflow-builder', () => ({
|
||||
parseAndValidate: jest.fn(() => ({
|
||||
vi.mock('../../../workflow-builder', () => ({
|
||||
parseAndValidate: vi.fn(() => ({
|
||||
workflow: {
|
||||
name: 'Generated workflow',
|
||||
nodes: [{ name: 'Webhook', type: 'n8n-nodes-base.webhook', parameters: {} }],
|
||||
|
|
@ -17,12 +17,12 @@ jest.mock('../../../workflow-builder', () => ({
|
|||
},
|
||||
warnings: [],
|
||||
})),
|
||||
partitionWarnings: jest.fn((warnings: unknown[]) => ({ errors: [], informational: warnings })),
|
||||
partitionWarnings: vi.fn((warnings: unknown[]) => ({ errors: [], informational: warnings })),
|
||||
}));
|
||||
|
||||
jest.mock('../resolve-credentials', () => ({
|
||||
buildCredentialMap: jest.fn(async () => await Promise.resolve(new Map())),
|
||||
resolveCredentials: jest.fn(
|
||||
vi.mock('../resolve-credentials', () => ({
|
||||
buildCredentialMap: vi.fn(async () => await Promise.resolve(new Map())),
|
||||
resolveCredentials: vi.fn(
|
||||
async () =>
|
||||
await Promise.resolve({
|
||||
mockedNodeNames: [],
|
||||
|
|
@ -34,12 +34,12 @@ jest.mock('../resolve-credentials', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
jest.mock('../setup-workflow.service', () => ({
|
||||
stripStaleCredentialsFromWorkflow: jest.fn(async () => await Promise.resolve()),
|
||||
vi.mock('../setup-workflow.service', () => ({
|
||||
stripStaleCredentialsFromWorkflow: vi.fn(async () => await Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../submit-workflow.tool', () => ({
|
||||
ensureWebhookIds: jest.fn(async () => await Promise.resolve()),
|
||||
vi.mock('../submit-workflow.tool', () => ({
|
||||
ensureWebhookIds: vi.fn(async () => await Promise.resolve()),
|
||||
}));
|
||||
|
||||
describe('createBuildWorkflowTool', () => {
|
||||
|
|
@ -53,7 +53,7 @@ describe('createBuildWorkflowTool', () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
restoreBuildViaPlanGuard();
|
||||
});
|
||||
|
||||
|
|
@ -66,15 +66,15 @@ describe('createBuildWorkflowTool', () => {
|
|||
userId: 'user-1',
|
||||
runId: 'run-1',
|
||||
workflowService: {
|
||||
createFromWorkflowJSON: jest.fn(),
|
||||
clearAiTemporary: jest.fn(),
|
||||
createFromWorkflowJSON: vi.fn(),
|
||||
clearAiTemporary: vi.fn(),
|
||||
},
|
||||
credentialService: {},
|
||||
nodeService: {},
|
||||
dataTableService: {},
|
||||
executionService: {},
|
||||
permissions: { createWorkflow: 'always_allow' },
|
||||
logger: { warn: jest.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
} as unknown as InstanceAiContext;
|
||||
|
||||
const tool = createBuildWorkflowTool(context);
|
||||
|
|
@ -90,13 +90,13 @@ describe('createBuildWorkflowTool', () => {
|
|||
});
|
||||
|
||||
it('aborts after repeated new workflow build plan-guard rejections', async () => {
|
||||
const warn = jest.fn();
|
||||
const warn = vi.fn();
|
||||
const context = {
|
||||
userId: 'user-1',
|
||||
runId: 'run-1',
|
||||
workflowService: {
|
||||
createFromWorkflowJSON: jest.fn(),
|
||||
clearAiTemporary: jest.fn(),
|
||||
createFromWorkflowJSON: vi.fn(),
|
||||
clearAiTemporary: vi.fn(),
|
||||
},
|
||||
credentialService: {},
|
||||
nodeService: {},
|
||||
|
|
@ -129,15 +129,15 @@ describe('createBuildWorkflowTool', () => {
|
|||
userId: 'user-1',
|
||||
runId: 'run-1',
|
||||
workflowService: {
|
||||
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
|
||||
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
credentialService: {},
|
||||
nodeService: {},
|
||||
dataTableService: {},
|
||||
executionService: {},
|
||||
permissions: { createWorkflow: 'always_allow' },
|
||||
logger: { warn: jest.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
} as unknown as InstanceAiContext;
|
||||
|
||||
const tool = createBuildWorkflowTool(context);
|
||||
|
|
@ -155,15 +155,15 @@ describe('createBuildWorkflowTool', () => {
|
|||
});
|
||||
|
||||
it('allows new workflow builds during post-plan follow-up repairs', async () => {
|
||||
const reportBuildOutcome = jest.fn(
|
||||
const reportBuildOutcome = vi.fn(
|
||||
async () => await Promise.resolve({ type: 'verify' as const, workflowId: 'wf-1' }),
|
||||
);
|
||||
const context = {
|
||||
userId: 'user-1',
|
||||
runId: 'run-1',
|
||||
workflowService: {
|
||||
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
|
||||
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
credentialService: {},
|
||||
nodeService: {},
|
||||
|
|
@ -180,7 +180,7 @@ describe('createBuildWorkflowTool', () => {
|
|||
},
|
||||
},
|
||||
permissions: { createWorkflow: 'always_allow' },
|
||||
logger: { warn: jest.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
} as unknown as InstanceAiContext;
|
||||
|
||||
const tool = createBuildWorkflowTool(context);
|
||||
|
|
@ -206,16 +206,16 @@ describe('createBuildWorkflowTool', () => {
|
|||
});
|
||||
|
||||
it('updates existing workflows during post-plan follow-ups without redundant approval', async () => {
|
||||
const reportBuildOutcome = jest.fn(
|
||||
const reportBuildOutcome = vi.fn(
|
||||
async () => await Promise.resolve({ type: 'verify' as const, workflowId: 'wf-1' }),
|
||||
);
|
||||
const suspend = jest.fn();
|
||||
const suspend = vi.fn();
|
||||
const context = {
|
||||
userId: 'user-1',
|
||||
runId: 'run-1',
|
||||
workflowService: {
|
||||
updateFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
|
||||
updateFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
credentialService: {},
|
||||
nodeService: {},
|
||||
|
|
@ -232,7 +232,7 @@ describe('createBuildWorkflowTool', () => {
|
|||
},
|
||||
},
|
||||
permissions: { updateWorkflow: 'ask' },
|
||||
logger: { warn: jest.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
} as unknown as InstanceAiContext;
|
||||
|
||||
const tool = createBuildWorkflowTool(context);
|
||||
|
|
@ -263,18 +263,17 @@ describe('createBuildWorkflowTool', () => {
|
|||
});
|
||||
|
||||
it('does not finalize the planned task when saving a supporting workflow', async () => {
|
||||
const reportBuildOutcome = jest.fn<
|
||||
Promise<{ type: 'verify'; workflowId: string }>,
|
||||
[WorkflowBuildOutcome]
|
||||
const reportBuildOutcome = vi.fn<
|
||||
(outcome: WorkflowBuildOutcome) => Promise<{ type: 'verify'; workflowId: string }>
|
||||
>(async () => await Promise.resolve({ type: 'verify', workflowId: 'wf-support' }));
|
||||
const markSucceeded = jest.fn(async () => await Promise.resolve(null));
|
||||
const onBuildOutcome = jest.fn();
|
||||
const markSucceeded = vi.fn(async () => await Promise.resolve(null));
|
||||
const onBuildOutcome = vi.fn();
|
||||
const context = {
|
||||
userId: 'user-1',
|
||||
runId: 'run-1',
|
||||
workflowService: {
|
||||
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-support' })),
|
||||
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
|
||||
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-support' })),
|
||||
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
credentialService: {},
|
||||
nodeService: {},
|
||||
|
|
@ -294,7 +293,7 @@ describe('createBuildWorkflowTool', () => {
|
|||
onBuildOutcome,
|
||||
},
|
||||
permissions: { createWorkflow: 'always_allow' },
|
||||
logger: { warn: jest.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
} as unknown as InstanceAiContext;
|
||||
|
||||
const tool = createBuildWorkflowTool(context);
|
||||
|
|
@ -324,19 +323,22 @@ describe('createBuildWorkflowTool', () => {
|
|||
});
|
||||
|
||||
it('reports a workflow-loop outcome when saving succeeds', async () => {
|
||||
const reportBuildOutcome = jest.fn(
|
||||
const reportBuildOutcome = vi.fn(
|
||||
async () => await Promise.resolve({ type: 'verify' as const, workflowId: 'wf-1' }),
|
||||
);
|
||||
const markSucceeded = jest.fn<
|
||||
Promise<null>,
|
||||
[string, string, { result?: string; outcome?: WorkflowBuildOutcome }]
|
||||
const markSucceeded = vi.fn<
|
||||
(
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
update: { result?: string; outcome?: WorkflowBuildOutcome },
|
||||
) => Promise<null>
|
||||
>(async () => await Promise.resolve(null));
|
||||
const context = {
|
||||
userId: 'user-1',
|
||||
runId: 'run-1',
|
||||
workflowService: {
|
||||
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: jest.fn(async () => await Promise.resolve()),
|
||||
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
credentialService: {},
|
||||
nodeService: {},
|
||||
|
|
@ -355,7 +357,7 @@ describe('createBuildWorkflowTool', () => {
|
|||
},
|
||||
},
|
||||
permissions: { createWorkflow: 'always_allow' },
|
||||
logger: { warn: jest.fn() },
|
||||
logger: { warn: vi.fn() },
|
||||
} as unknown as InstanceAiContext;
|
||||
|
||||
const tool = createBuildWorkflowTool(context);
|
||||
|
|
@ -395,13 +397,13 @@ describe('createBuildWorkflowTool', () => {
|
|||
});
|
||||
|
||||
it('keeps the build successful when main workflow promotion fails', async () => {
|
||||
const warn = jest.fn();
|
||||
const warn = vi.fn();
|
||||
const context = {
|
||||
userId: 'user-1',
|
||||
runId: 'run-1',
|
||||
workflowService: {
|
||||
createFromWorkflowJSON: jest.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: jest.fn(async () => {
|
||||
createFromWorkflowJSON: vi.fn(async () => await Promise.resolve({ id: 'wf-1' })),
|
||||
clearAiTemporary: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('temporary marker cleanup failed');
|
||||
}),
|
||||
|
|
@ -416,12 +418,12 @@ describe('createBuildWorkflowTool', () => {
|
|||
taskId: 'task-1',
|
||||
workItemId: 'wi-1',
|
||||
workflowTaskService: {
|
||||
reportBuildOutcome: jest.fn(
|
||||
reportBuildOutcome: vi.fn(
|
||||
async () => await Promise.resolve({ type: 'verify' as const, workflowId: 'wf-1' }),
|
||||
),
|
||||
},
|
||||
plannedTaskService: {
|
||||
markSucceeded: jest.fn(async () => await Promise.resolve(null)),
|
||||
markSucceeded: vi.fn(async () => await Promise.resolve(null)),
|
||||
},
|
||||
},
|
||||
permissions: { createWorkflow: 'always_allow' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorkflowJSON, NodeJSON } from '@n8n/workflow-sdk';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { InstanceAiContext } from '../../../types';
|
||||
import {
|
||||
|
|
@ -19,51 +20,51 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiCo
|
|||
return {
|
||||
userId: 'test-user',
|
||||
workflowService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getAsWorkflowJSON: jest.fn(),
|
||||
createFromWorkflowJSON: jest.fn(),
|
||||
updateFromWorkflowJSON: jest.fn(),
|
||||
archive: jest.fn(),
|
||||
unarchive: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
unpublish: jest.fn(),
|
||||
clearAiTemporary: jest.fn(),
|
||||
archiveIfAiTemporary: jest.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getAsWorkflowJSON: vi.fn(),
|
||||
createFromWorkflowJSON: vi.fn(),
|
||||
updateFromWorkflowJSON: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
unarchive: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
unpublish: vi.fn(),
|
||||
clearAiTemporary: vi.fn(),
|
||||
archiveIfAiTemporary: vi.fn(),
|
||||
},
|
||||
executionService: {
|
||||
list: jest.fn(),
|
||||
run: jest.fn(),
|
||||
getStatus: jest.fn(),
|
||||
getResult: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
getDebugInfo: jest.fn(),
|
||||
getNodeOutput: jest.fn(),
|
||||
getResolvedNodeParameters: jest.fn(),
|
||||
list: vi.fn(),
|
||||
run: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getResult: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
getDebugInfo: vi.fn(),
|
||||
getNodeOutput: vi.fn(),
|
||||
getResolvedNodeParameters: vi.fn(),
|
||||
},
|
||||
credentialService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
test: jest.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
test: vi.fn(),
|
||||
},
|
||||
nodeService: {
|
||||
listAvailable: jest.fn(),
|
||||
getDescription: jest.fn(),
|
||||
listSearchable: jest.fn(),
|
||||
listAvailable: vi.fn(),
|
||||
getDescription: vi.fn(),
|
||||
listSearchable: vi.fn(),
|
||||
},
|
||||
dataTableService: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
getSchema: jest.fn(),
|
||||
addColumn: jest.fn(),
|
||||
deleteColumn: jest.fn(),
|
||||
renameColumn: jest.fn(),
|
||||
queryRows: jest.fn(),
|
||||
insertRows: jest.fn(),
|
||||
updateRows: jest.fn(),
|
||||
deleteRows: jest.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getSchema: vi.fn(),
|
||||
addColumn: vi.fn(),
|
||||
deleteColumn: vi.fn(),
|
||||
renameColumn: vi.fn(),
|
||||
queryRows: vi.fn(),
|
||||
insertRows: vi.fn(),
|
||||
updateRows: vi.fn(),
|
||||
deleteRows: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -97,12 +98,12 @@ describe('buildSetupRequests', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [{ name: 'slackApi' }],
|
||||
});
|
||||
// Default: credential test passes (override in specific tests for failure cases)
|
||||
(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
|
||||
(context.credentialService.test as Mock).mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it('skips disabled nodes', async () => {
|
||||
|
|
@ -118,7 +119,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('detects credential types from node description', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
|
||||
|
|
@ -134,10 +135,10 @@ describe('buildSetupRequests', () => {
|
|||
// Simulate production: getNodeCredentialTypes is available but returns []
|
||||
// (e.g. node lookup miss in the adapter). The fallback should still detect
|
||||
// credentials from the node description.
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode();
|
||||
const result = await buildSetupRequests(context, node);
|
||||
|
|
@ -148,10 +149,10 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('falls back to node description credentials when getNodeCredentialTypes throws', async () => {
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Node lookup failed'));
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode();
|
||||
const result = await buildSetupRequests(context, node);
|
||||
|
|
@ -162,16 +163,16 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('excludes credentials whose displayOptions do not match current parameters', async () => {
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [
|
||||
{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
|
||||
],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode({ type: 'n8n-nodes-base.httpRequest', typeVersion: 4.4 });
|
||||
const result = await buildSetupRequests(context, node);
|
||||
|
|
@ -181,16 +182,16 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('includes credentials whose displayOptions match current parameters', async () => {
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [
|
||||
{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
|
||||
],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
|
|
@ -203,16 +204,16 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('resolves dynamic credential from genericAuthType parameter', async () => {
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [
|
||||
{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
|
||||
],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
|
|
@ -231,14 +232,14 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('resolves dynamic credential from predefinedCredentialType parameter', async () => {
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
|
|
@ -257,7 +258,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('sets needsAction=true when no credential is set', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
|
||||
|
|
@ -268,10 +269,10 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('sets needsAction=false when credential is set and test passes', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
(context.credentialService.test as jest.Mock).mockResolvedValue({
|
||||
(context.credentialService.test as Mock).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
|
|
@ -284,10 +285,10 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('sets needsAction=true when credential test fails', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
(context.credentialService.test as jest.Mock).mockResolvedValue({
|
||||
(context.credentialService.test as Mock).mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Invalid token',
|
||||
});
|
||||
|
|
@ -301,12 +302,12 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('sets needsAction=true when parameter issues exist', async () => {
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
properties: [{ name: 'resource', displayName: 'Resource', type: 'string' }],
|
||||
});
|
||||
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
resource: ['Parameter "resource" is required'],
|
||||
|
|
@ -321,7 +322,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('auto-applies the only credential when node has none', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
|
||||
|
|
@ -333,7 +334,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('does not auto-apply when multiple credentials of the same type exist', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-2', name: 'Newer Slack', updatedAt: '2025-06-01T00:00:00.000Z' },
|
||||
{ id: 'cred-1', name: 'Older Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
|
|
@ -350,7 +351,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('sets isAutoApplied=false when node already has credential', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
|
||||
|
|
@ -363,7 +364,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('uses credential cache to avoid duplicate fetches', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
|
||||
|
|
@ -379,7 +380,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('forwards workflowId to credentialService.list so candidates match save-time scope', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode();
|
||||
await buildSetupRequests(context, node, undefined, undefined, 'wf-1');
|
||||
|
|
@ -391,7 +392,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('omits workflowId from credentialService.list when not provided', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode();
|
||||
await buildSetupRequests(context, node);
|
||||
|
|
@ -400,7 +401,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('cache discriminates by workflowId so a shared cache stays correct across workflows', async () => {
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const cache = createCredentialCache();
|
||||
const node = makeNode();
|
||||
|
|
@ -418,10 +419,10 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('does not generate credential request for HTTP Request with auth=none and stale node.credentials', async () => {
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [
|
||||
{
|
||||
|
|
@ -430,7 +431,7 @@ describe('buildSetupRequests', () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode({
|
||||
name: 'HTTP Request',
|
||||
|
|
@ -447,7 +448,7 @@ describe('buildSetupRequests', () => {
|
|||
it('fallback: displayOptions filtering takes priority over stale node.credentials', async () => {
|
||||
// Remove getNodeCredentialTypes to force fallback path
|
||||
delete (context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes;
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [
|
||||
{
|
||||
|
|
@ -456,7 +457,7 @@ describe('buildSetupRequests', () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const node = makeNode({
|
||||
name: 'HTTP Request',
|
||||
|
|
@ -471,14 +472,14 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('fallback: node with assigned credentials matching description is still detected', async () => {
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [{ name: 'slackApi' }],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
|
||||
|
|
@ -493,11 +494,11 @@ describe('buildSetupRequests', () => {
|
|||
it('fallback: node.credentials with types not in description are excluded', async () => {
|
||||
// Remove getNodeCredentialTypes to force fallback path
|
||||
delete (context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes;
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [{ name: 'slackApi' }],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
|
||||
|
|
@ -514,7 +515,7 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('treats placeholder values as parameter issues', async () => {
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
properties: [{ name: 'email', displayName: 'Email', type: 'string' }],
|
||||
|
|
@ -533,12 +534,12 @@ describe('buildSetupRequests', () => {
|
|||
});
|
||||
|
||||
it('adds placeholder issue even when param already has validation issues', async () => {
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
properties: [{ name: 'email', displayName: 'Email', type: 'string', required: true }],
|
||||
});
|
||||
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ email: ['Parameter "Email" is required'] });
|
||||
|
||||
|
|
@ -571,10 +572,10 @@ describe('analyzeWorkflow', () => {
|
|||
});
|
||||
|
||||
it('returns empty array for workflow with no actionable nodes', async () => {
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([makeNode({ name: 'NoOp', type: 'n8n-nodes-base.noOp' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
});
|
||||
|
|
@ -584,14 +585,14 @@ describe('analyzeWorkflow', () => {
|
|||
});
|
||||
|
||||
it('includes nodes with credential types', async () => {
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([makeNode()]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [{ name: 'slackApi' }],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
expect(result).toHaveLength(1);
|
||||
|
|
@ -602,17 +603,15 @@ describe('analyzeWorkflow', () => {
|
|||
const node = makeNode({
|
||||
credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([node]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [{ name: 'slackApi' }],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
|
||||
(context.credentialService.test as Mock).mockResolvedValue({ success: true });
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -623,17 +622,15 @@ describe('analyzeWorkflow', () => {
|
|||
const node = makeNode({
|
||||
credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([node]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [{ name: 'slackApi' }],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
(context.credentialService.test as jest.Mock).mockResolvedValue({
|
||||
(context.credentialService.test as Mock).mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Invalid token',
|
||||
});
|
||||
|
|
@ -651,18 +648,18 @@ describe('analyzeWorkflow', () => {
|
|||
id: 'n-trigger',
|
||||
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'My Auth' } },
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([trigger]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: ['trigger'],
|
||||
credentials: [{ name: 'httpHeaderAuth' }],
|
||||
webhooks: [{}],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Auth', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
|
||||
(context.credentialService.test as Mock).mockResolvedValue({ success: true });
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -676,23 +673,21 @@ describe('analyzeWorkflow', () => {
|
|||
const node = makeNode({
|
||||
credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([node]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [{ name: 'slackApi' }],
|
||||
properties: [{ name: 'resource', displayName: 'Resource', type: 'string' }],
|
||||
});
|
||||
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
resource: ['Parameter "resource" is required'],
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
|
||||
(context.credentialService.test as Mock).mockResolvedValue({ success: true });
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -714,12 +709,12 @@ describe('analyzeWorkflow', () => {
|
|||
id: 'n-action',
|
||||
position: [400, 100] as [number, number],
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([action, trigger], {
|
||||
Webhook: { main: [[{ node: 'Slack', type: 'main', index: 0 }]] },
|
||||
}),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
|
||||
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
|
||||
if (type === 'n8n-nodes-base.webhook') {
|
||||
return await Promise.resolve({
|
||||
group: ['trigger'],
|
||||
|
|
@ -729,7 +724,7 @@ describe('analyzeWorkflow', () => {
|
|||
}
|
||||
return await Promise.resolve({ group: [], credentials: [{ name: 'slackApi' }] });
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -758,7 +753,7 @@ describe('analyzeWorkflow', () => {
|
|||
typeVersion: 1,
|
||||
id: 'memory-1',
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([agent, model, memory], {
|
||||
'OpenAI Model': {
|
||||
ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]],
|
||||
|
|
@ -766,7 +761,7 @@ describe('analyzeWorkflow', () => {
|
|||
Memory: { ai_memory: [[{ node: 'Agent', type: 'ai_memory', index: 0 }]] },
|
||||
}),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
|
||||
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
|
||||
if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
|
||||
return await Promise.resolve({
|
||||
group: [],
|
||||
|
|
@ -778,7 +773,7 @@ describe('analyzeWorkflow', () => {
|
|||
}
|
||||
return await Promise.resolve({ group: [], credentials: [] });
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -810,7 +805,7 @@ describe('analyzeWorkflow', () => {
|
|||
typeVersion: 1,
|
||||
id: 'sub-model-1',
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([agent, tool, subModel], {
|
||||
Tool: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] },
|
||||
'Sub Model': {
|
||||
|
|
@ -818,7 +813,7 @@ describe('analyzeWorkflow', () => {
|
|||
},
|
||||
}),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
|
||||
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
|
||||
if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
|
||||
return await Promise.resolve({
|
||||
group: [],
|
||||
|
|
@ -827,7 +822,7 @@ describe('analyzeWorkflow', () => {
|
|||
}
|
||||
return await Promise.resolve({ group: [], credentials: [] });
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -848,12 +843,12 @@ describe('analyzeWorkflow', () => {
|
|||
typeVersion: 1,
|
||||
id: 'model-1',
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([agent, model], {
|
||||
Model: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] },
|
||||
}),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
|
||||
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
|
||||
if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
|
||||
return await Promise.resolve({
|
||||
group: [],
|
||||
|
|
@ -863,7 +858,7 @@ describe('analyzeWorkflow', () => {
|
|||
// Agent itself returns no credentials → no setup request for it.
|
||||
return await Promise.resolve({ group: [], credentials: [] });
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -904,7 +899,7 @@ describe('analyzeWorkflow', () => {
|
|||
typeVersion: 1,
|
||||
id: 'shared-1',
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([trigger, agentA, agentB, sharedModel], {
|
||||
Trigger: {
|
||||
main: [
|
||||
|
|
@ -924,7 +919,7 @@ describe('analyzeWorkflow', () => {
|
|||
},
|
||||
}),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
|
||||
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
|
||||
if (type === 'n8n-nodes-base.webhook') {
|
||||
return await Promise.resolve({
|
||||
group: ['trigger'],
|
||||
|
|
@ -940,7 +935,7 @@ describe('analyzeWorkflow', () => {
|
|||
}
|
||||
return await Promise.resolve({ group: [], credentials: [] });
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -962,12 +957,12 @@ describe('analyzeWorkflow', () => {
|
|||
id: 'http-1',
|
||||
position: [200, 0] as [number, number],
|
||||
});
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflowJSON([trigger, httpAction], {
|
||||
Trigger: { main: [[{ node: 'HTTP', type: 'main', index: 0 }]] },
|
||||
}),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockImplementation(async (type: string) => {
|
||||
(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
|
||||
if (type === 'n8n-nodes-base.webhook') {
|
||||
return await Promise.resolve({
|
||||
group: ['trigger'],
|
||||
|
|
@ -980,7 +975,7 @@ describe('analyzeWorkflow', () => {
|
|||
credentials: [{ name: 'httpBasicAuth' }],
|
||||
});
|
||||
});
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await analyzeWorkflow(context, 'wf-1');
|
||||
|
||||
|
|
@ -1006,11 +1001,11 @@ describe('applyNodeChanges', () => {
|
|||
makeNode({ name: 'Slack', id: 'n1' }),
|
||||
makeNode({ name: 'Gmail', id: 'n2', type: 'n8n-nodes-base.gmail' }),
|
||||
]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as jest.Mock).mockImplementation(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as Mock).mockImplementation(
|
||||
async (id: string) => await Promise.resolve({ id, name: `Cred ${id}` }),
|
||||
);
|
||||
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await applyNodeChanges(
|
||||
context,
|
||||
|
|
@ -1028,9 +1023,9 @@ describe('applyNodeChanges', () => {
|
|||
|
||||
it('reports failures when credential is not found', async () => {
|
||||
const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as jest.Mock).mockResolvedValue(undefined);
|
||||
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as Mock).mockResolvedValue(undefined);
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await applyNodeChanges(context, 'wf-1', {
|
||||
Slack: { slackApi: 'nonexistent' },
|
||||
|
|
@ -1042,12 +1037,12 @@ describe('applyNodeChanges', () => {
|
|||
|
||||
it('rolls back applied nodes on save failure', async () => {
|
||||
const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as Mock).mockResolvedValue({
|
||||
id: 'cred-1',
|
||||
name: 'My Slack',
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockRejectedValue(
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockRejectedValue(
|
||||
new Error('DB error'),
|
||||
);
|
||||
|
||||
|
|
@ -1069,8 +1064,8 @@ describe('applyNodeChanges', () => {
|
|||
credentials: { httpHeaderAuth: { id: 'stale', name: 'Stale Header Auth' } },
|
||||
});
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [
|
||||
{
|
||||
|
|
@ -1079,11 +1074,11 @@ describe('applyNodeChanges', () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await applyNodeChanges(context, 'wf-1');
|
||||
|
||||
const calls = (context.workflowService.updateFromWorkflowJSON as jest.Mock).mock.calls as Array<
|
||||
const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
|
||||
[string, WorkflowJSON]
|
||||
>;
|
||||
const savedJson = calls[0][1];
|
||||
|
|
@ -1099,22 +1094,22 @@ describe('applyNodeChanges', () => {
|
|||
parameters: { authentication: 'none' },
|
||||
});
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
});
|
||||
(context.credentialService.get as jest.Mock).mockResolvedValue({
|
||||
(context.credentialService.get as Mock).mockResolvedValue({
|
||||
id: 'cred-1',
|
||||
name: 'My Header Auth',
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await applyNodeChanges(context, 'wf-1', {
|
||||
'HTTP Request': { httpHeaderAuth: 'cred-1' },
|
||||
});
|
||||
|
||||
const calls = (context.workflowService.updateFromWorkflowJSON as jest.Mock).mock.calls as Array<
|
||||
const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
|
||||
[string, WorkflowJSON]
|
||||
>;
|
||||
const savedJson = calls[0][1];
|
||||
|
|
@ -1134,12 +1129,12 @@ describe('applyNodeChanges', () => {
|
|||
parameters: { method: 'GET', url: '', authentication: 'none' },
|
||||
});
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await applyNodeChanges(context, 'wf-1', undefined, {
|
||||
'HTTP Request': { url: 'https://example.com/api' },
|
||||
|
|
@ -1148,7 +1143,7 @@ describe('applyNodeChanges', () => {
|
|||
expect(result.applied).toContain('HTTP Request');
|
||||
expect(result.failed).toHaveLength(0);
|
||||
|
||||
const calls = (context.workflowService.updateFromWorkflowJSON as jest.Mock).mock.calls as Array<
|
||||
const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
|
||||
[string, WorkflowJSON]
|
||||
>;
|
||||
expect(calls).toHaveLength(1);
|
||||
|
|
@ -1172,16 +1167,16 @@ describe('applyNodeChanges', () => {
|
|||
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Header Auth' } },
|
||||
});
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await applyNodeChanges(context, 'wf-1');
|
||||
|
||||
const calls = (context.workflowService.updateFromWorkflowJSON as jest.Mock).mock.calls as Array<
|
||||
const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
|
||||
[string, WorkflowJSON]
|
||||
>;
|
||||
const savedJson = calls[0][1];
|
||||
|
|
@ -1247,7 +1242,7 @@ describe('stripStaleCredentialsFromWorkflow', () => {
|
|||
credentials: { httpHeaderAuth: { id: 'stale', name: 'Stale Header Auth' } },
|
||||
});
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [
|
||||
{
|
||||
|
|
@ -1274,7 +1269,7 @@ describe('stripStaleCredentialsFromWorkflow', () => {
|
|||
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Header Auth' } },
|
||||
});
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
});
|
||||
|
|
@ -1306,7 +1301,7 @@ describe('stripStaleCredentialsFromWorkflow', () => {
|
|||
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'OpenRouter Auth' } },
|
||||
});
|
||||
const wfJson = makeWorkflowJSON([cleanNode, staleNode]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [
|
||||
{
|
||||
|
|
@ -1329,7 +1324,7 @@ describe('stripStaleCredentialsFromWorkflow', () => {
|
|||
parameters: { authentication: 'none' },
|
||||
});
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue({
|
||||
group: [],
|
||||
credentials: [],
|
||||
});
|
||||
|
|
@ -1361,8 +1356,8 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
|
|||
it('applies a credential the user is allowed to read', async () => {
|
||||
const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as jest.Mock).mockResolvedValue({
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as Mock).mockResolvedValue({
|
||||
id: 'cred-mine',
|
||||
name: 'My Slack',
|
||||
type: 'slackApi',
|
||||
|
|
@ -1382,8 +1377,8 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
|
|||
it('does not write a credential the user cannot access', async () => {
|
||||
const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as jest.Mock).mockRejectedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as Mock).mockRejectedValue(
|
||||
new Error('Credential with ID "cred-other" could not be found.'),
|
||||
);
|
||||
|
||||
|
|
@ -1402,8 +1397,8 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
|
|||
const slack = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
|
||||
const github = makeNode({ name: 'GitHub', type: 'n8n-nodes-base.github', id: 'node-2' });
|
||||
const wfJson = makeWorkflowJSON([slack, github]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as jest.Mock).mockImplementation(async (credId: string) => {
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as Mock).mockImplementation(async (credId: string) => {
|
||||
if (credId === 'cred-mine') {
|
||||
return await Promise.resolve({ id: 'cred-mine', name: 'My Slack', type: 'slackApi' });
|
||||
}
|
||||
|
|
@ -1425,8 +1420,8 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
|
|||
it('marks a node as failed when any of its credentials are rejected', async () => {
|
||||
const node = makeNode({ name: 'HTTP', type: 'n8n-nodes-base.httpRequest' });
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as jest.Mock).mockImplementation(async (credId: string) => {
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
(context.credentialService.get as Mock).mockImplementation(async (credId: string) => {
|
||||
if (credId === 'cred-mine') {
|
||||
return await Promise.resolve({ id: 'cred-mine', name: 'Auth', type: 'httpHeaderAuth' });
|
||||
}
|
||||
|
|
@ -1448,7 +1443,7 @@ describe('applyNodeCredentials — credential ownership revalidation', () => {
|
|||
it('skips credentials for nodes not present in the workflow', async () => {
|
||||
const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
|
||||
const wfJson = makeWorkflowJSON([node]);
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
|
||||
|
||||
const result = await applyNodeCredentials(context, 'wf-1', {
|
||||
GhostNode: { slackApi: 'cred-other' },
|
||||
|
|
|
|||
|
|
@ -211,11 +211,11 @@ describe('wrapSubmitExecuteWithIdentity', () => {
|
|||
});
|
||||
|
||||
it('blocks submit when persisted remediation says editing must stop', async () => {
|
||||
const execute = jest.fn(async (): Promise<SubmitWorkflowOutput> => {
|
||||
const execute = vi.fn(async (): Promise<SubmitWorkflowOutput> => {
|
||||
await Promise.resolve();
|
||||
return { success: true, workflowId: 'wf_unused' };
|
||||
});
|
||||
const onGuardFired = jest.fn();
|
||||
const onGuardFired = vi.fn();
|
||||
const state: WorkflowLoopState = {
|
||||
workItemId: 'wi_test',
|
||||
threadId: 'thread_1',
|
||||
|
|
@ -260,8 +260,8 @@ describe('wrapSubmitExecuteWithIdentity', () => {
|
|||
reason: 'workflow_save_failed',
|
||||
guidance: 'Stop editing.',
|
||||
});
|
||||
const execute = jest
|
||||
.fn<Promise<SubmitWorkflowOutput>, [SubmitWorkflowInput]>()
|
||||
const execute = vi
|
||||
.fn<(...args: [SubmitWorkflowInput]) => Promise<SubmitWorkflowOutput>>()
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
errors: ['Workflow save failed.'],
|
||||
|
|
@ -299,7 +299,7 @@ describe('wrapSubmitExecuteWithIdentity', () => {
|
|||
reason: 'workflow_save_failed',
|
||||
guidance: 'Stop editing.',
|
||||
});
|
||||
const execute = jest.fn(async (): Promise<SubmitWorkflowOutput> => {
|
||||
const execute = vi.fn(async (): Promise<SubmitWorkflowOutput> => {
|
||||
await gate;
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -329,7 +329,7 @@ describe('wrapSubmitExecuteWithIdentity', () => {
|
|||
});
|
||||
|
||||
it('ignores terminal remediation from a previous run', async () => {
|
||||
const execute = jest.fn(async (): Promise<SubmitWorkflowOutput> => {
|
||||
const execute = vi.fn(async (): Promise<SubmitWorkflowOutput> => {
|
||||
await Promise.resolve();
|
||||
return { success: true, workflowId: 'wf_current' };
|
||||
});
|
||||
|
|
@ -379,12 +379,12 @@ describe('wrapSubmitExecuteWithIdentity', () => {
|
|||
successfulSubmitSeen: true,
|
||||
postSubmitRemediationSubmitsUsed: 2,
|
||||
};
|
||||
const getWorkflowLoopState = jest
|
||||
.fn<Promise<WorkflowLoopState | undefined>, []>()
|
||||
const getWorkflowLoopState = vi
|
||||
.fn<(...args: []) => Promise<WorkflowLoopState | undefined>>()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(terminalState);
|
||||
const execute = jest.fn(async (input: SubmitWorkflowInput): Promise<SubmitWorkflowOutput> => {
|
||||
const execute = vi.fn(async (input: SubmitWorkflowInput): Promise<SubmitWorkflowOutput> => {
|
||||
await gate;
|
||||
return { success: true, workflowId: input.workflowId ?? 'wf_1' };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { NodeJSON, WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { InstanceAiContext, NodeDescription } from '../../../types';
|
||||
import { validateWorkflowConfig } from '../validate-workflow.service';
|
||||
|
|
@ -11,51 +12,51 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiCo
|
|||
return {
|
||||
userId: 'test-user',
|
||||
workflowService: {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getAsWorkflowJSON: jest.fn(),
|
||||
createFromWorkflowJSON: jest.fn(),
|
||||
updateFromWorkflowJSON: jest.fn(),
|
||||
archive: jest.fn(),
|
||||
unarchive: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
unpublish: jest.fn(),
|
||||
clearAiTemporary: jest.fn(),
|
||||
archiveIfAiTemporary: jest.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getAsWorkflowJSON: vi.fn(),
|
||||
createFromWorkflowJSON: vi.fn(),
|
||||
updateFromWorkflowJSON: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
unarchive: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
unpublish: vi.fn(),
|
||||
clearAiTemporary: vi.fn(),
|
||||
archiveIfAiTemporary: vi.fn(),
|
||||
},
|
||||
executionService: {
|
||||
list: jest.fn(),
|
||||
run: jest.fn(),
|
||||
getStatus: jest.fn(),
|
||||
getResult: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
getDebugInfo: jest.fn(),
|
||||
getNodeOutput: jest.fn(),
|
||||
list: vi.fn(),
|
||||
run: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getResult: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
getDebugInfo: vi.fn(),
|
||||
getNodeOutput: vi.fn(),
|
||||
},
|
||||
credentialService: {
|
||||
list: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
test: jest.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
test: vi.fn(),
|
||||
},
|
||||
nodeService: {
|
||||
listAvailable: jest.fn(),
|
||||
getDescription: jest.fn(),
|
||||
listSearchable: jest.fn(),
|
||||
getParameterIssues: jest.fn().mockResolvedValue({}),
|
||||
listAvailable: vi.fn(),
|
||||
getDescription: vi.fn(),
|
||||
listSearchable: vi.fn(),
|
||||
getParameterIssues: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
dataTableService: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
getSchema: jest.fn(),
|
||||
addColumn: jest.fn(),
|
||||
deleteColumn: jest.fn(),
|
||||
renameColumn: jest.fn(),
|
||||
queryRows: jest.fn(),
|
||||
insertRows: jest.fn(),
|
||||
updateRows: jest.fn(),
|
||||
deleteRows: jest.fn(),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getSchema: vi.fn(),
|
||||
addColumn: vi.fn(),
|
||||
deleteColumn: vi.fn(),
|
||||
renameColumn: vi.fn(),
|
||||
queryRows: vi.fn(),
|
||||
insertRows: vi.fn(),
|
||||
updateRows: vi.fn(),
|
||||
deleteRows: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as InstanceAiContext;
|
||||
|
|
@ -116,7 +117,7 @@ describe('validateWorkflowConfig', () => {
|
|||
describe('credential issues', () => {
|
||||
it('flags a node whose required credential is unset', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'telegramApi', required: true }],
|
||||
}),
|
||||
|
|
@ -136,12 +137,12 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('does not flag a required credential when one is set and exists for the user', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'telegramApi', required: true }],
|
||||
}),
|
||||
);
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'My Telegram', type: 'telegramApi' },
|
||||
]);
|
||||
const node = makeNode({
|
||||
|
|
@ -156,12 +157,12 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('emits a "do not exist" issue when the selected credential is not in the user\'s store', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'telegramApi', required: true }],
|
||||
}),
|
||||
);
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([]);
|
||||
(context.credentialService.list as Mock).mockResolvedValue([]);
|
||||
const node = makeNode({
|
||||
credentials: { telegramApi: { id: 'cred-missing', name: 'Foreign Telegram' } },
|
||||
});
|
||||
|
|
@ -175,12 +176,12 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('emits a "not identified" issue when multiple stored credentials match the selected name', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'telegramApi', required: true }],
|
||||
}),
|
||||
);
|
||||
(context.credentialService.list as jest.Mock).mockResolvedValue([
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
{ id: 'cred-1', name: 'Same Name', type: 'telegramApi' },
|
||||
{ id: 'cred-2', name: 'Same Name', type: 'telegramApi' },
|
||||
]);
|
||||
|
|
@ -197,7 +198,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('skips AI-gateway-managed credentials', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'openAiApi', required: true }],
|
||||
}),
|
||||
|
|
@ -221,7 +222,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('respects displayOptions when filtering credential types', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [
|
||||
{
|
||||
|
|
@ -258,7 +259,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('flags an HTTP Request node with genericCredentialType but no credentials set', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(httpRequestDescription);
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(httpRequestDescription);
|
||||
const node = makeNode({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4,
|
||||
|
|
@ -278,7 +279,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('flags an HTTP Request node with predefinedCredentialType missing credentials', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(httpRequestDescription);
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(httpRequestDescription);
|
||||
const node = makeNode({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4,
|
||||
|
|
@ -299,10 +300,10 @@ describe('validateWorkflowConfig', () => {
|
|||
describe('parameter issues', () => {
|
||||
it('surfaces parameter validation errors from the node service', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
(context.nodeService.getParameterIssues as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getParameterIssues as Mock).mockResolvedValue({
|
||||
chatId: ['Parameter "chatId" is required'],
|
||||
});
|
||||
const node = makeNode({ parameters: { chatId: '' } });
|
||||
|
|
@ -319,12 +320,12 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('honors ignoreIssues to suppress whole categories', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'telegramApi', required: true }],
|
||||
}),
|
||||
);
|
||||
(context.nodeService.getParameterIssues as jest.Mock).mockResolvedValue({
|
||||
(context.nodeService.getParameterIssues as Mock).mockResolvedValue({
|
||||
chatId: ['Parameter "chatId" is required'],
|
||||
});
|
||||
const node = makeNode({ parameters: { chatId: '' } });
|
||||
|
|
@ -342,7 +343,7 @@ describe('validateWorkflowConfig', () => {
|
|||
describe('node-level skips', () => {
|
||||
it('skips disabled nodes', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'telegramApi', required: true }],
|
||||
}),
|
||||
|
|
@ -357,7 +358,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('emits typeUnknown when the node description cannot be resolved', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockRejectedValue(new Error('not found'));
|
||||
(context.nodeService.getDescription as Mock).mockRejectedValue(new Error('not found'));
|
||||
const node = makeNode({ type: 'n8n-nodes-base.madeUpType' });
|
||||
|
||||
const result = await validateWorkflowConfig(context, { workflow: makeWorkflow([node]) });
|
||||
|
|
@ -371,10 +372,8 @@ describe('validateWorkflowConfig', () => {
|
|||
it('resolves the workflow via workflowService.getAsWorkflowJSON', async () => {
|
||||
const context = createMockContext();
|
||||
const node = makeNode();
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
makeWorkflow([node]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflow([node]));
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'telegramApi', required: true }],
|
||||
}),
|
||||
|
|
@ -396,7 +395,7 @@ describe('validateWorkflowConfig', () => {
|
|||
context: InstanceAiContext,
|
||||
perNodeInputs: Record<string, unknown[]>,
|
||||
): void {
|
||||
(context.nodeService as unknown as Record<string, unknown>).getResolvedNodeInputs = jest
|
||||
(context.nodeService as unknown as Record<string, unknown>).getResolvedNodeInputs = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_workflow: WorkflowJSON, nodeName: string) => {
|
||||
return await Promise.resolve(perNodeInputs[nodeName] ?? []);
|
||||
|
|
@ -405,7 +404,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('flags an AI Agent node missing its required ai_languageModel attachment', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
|
|
@ -445,7 +444,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('does not flag an AI Agent when the language model is attached', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
|
|
@ -493,7 +492,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('does not flag optional inputs (required !== true)', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
|
|
@ -515,7 +514,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('does not flag plain-string inputs (no required field)', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
// Standard nodes have plain `'main'` string inputs — never carry a required flag.
|
||||
|
|
@ -531,7 +530,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('honors ignoreIssues: ["input"] to suppress input issues', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
|
|
@ -554,7 +553,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('skips input checks entirely when getResolvedNodeInputs is not implemented', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
// Adapter optionality: no getResolvedNodeInputs method on the service.
|
||||
|
|
@ -568,7 +567,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('uses the input.type as fallback message label when displayName is missing', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
|
|
@ -593,7 +592,7 @@ describe('validateWorkflowConfig', () => {
|
|||
context: InstanceAiContext,
|
||||
runData: Record<string, unknown[]> | null,
|
||||
): void {
|
||||
(context.workflowService as unknown as Record<string, unknown>).getLatestRunData = jest
|
||||
(context.workflowService as unknown as Record<string, unknown>).getLatestRunData = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_workflowId: string) => {
|
||||
return await Promise.resolve(runData);
|
||||
|
|
@ -602,10 +601,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('flags a node whose most recent execution had an error', async () => {
|
||||
const context = createMockContext();
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
stubLatestRunData(context, {
|
||||
|
|
@ -623,10 +622,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('does not flag a node whose most recent execution completed without error', async () => {
|
||||
const context = createMockContext();
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
stubLatestRunData(context, {
|
||||
|
|
@ -641,10 +640,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('does not flag any execution issues when the workflow has no execution history', async () => {
|
||||
const context = createMockContext();
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
stubLatestRunData(context, null);
|
||||
|
|
@ -656,7 +655,7 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('skips execution checks silently in inline-workflow mode', async () => {
|
||||
const context = createMockContext();
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
// Even though getLatestRunData would return errors, inline mode has no
|
||||
|
|
@ -674,10 +673,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('honors ignoreIssues: ["execution"] to suppress execution flags', async () => {
|
||||
const context = createMockContext();
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
stubLatestRunData(context, {
|
||||
|
|
@ -695,10 +694,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('skips execution checks when the adapter does not implement getLatestRunData', async () => {
|
||||
const context = createMockContext();
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
// No stubLatestRunData — the optional method is absent on this adapter.
|
||||
|
|
@ -710,10 +709,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('reports execution alongside parameter/credential issues on the same node', async () => {
|
||||
const context = createMockContext();
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode()]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({
|
||||
credentials: [{ name: 'telegramApi', required: true }],
|
||||
}),
|
||||
|
|
@ -737,10 +736,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('handles task errors without a message field gracefully', async () => {
|
||||
const context = createMockContext();
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
stubLatestRunData(context, {
|
||||
|
|
@ -765,7 +764,7 @@ describe('validateWorkflowConfig', () => {
|
|||
context: InstanceAiContext,
|
||||
runData: Record<string, unknown[]> | null,
|
||||
): void {
|
||||
(context.workflowService as unknown as Record<string, unknown>).getLatestRunData = jest
|
||||
(context.workflowService as unknown as Record<string, unknown>).getLatestRunData = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_workflowId: string) => {
|
||||
return await Promise.resolve(runData);
|
||||
|
|
@ -774,10 +773,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('drops the error message text from the summary when allowSendingParameterValues is false', async () => {
|
||||
const context = createMockContext({ allowSendingParameterValues: false });
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
stubLatestRunData(context, {
|
||||
|
|
@ -799,10 +798,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('includes the error message when allowSendingParameterValues is true', async () => {
|
||||
const context = createMockContext({ allowSendingParameterValues: true });
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
stubLatestRunData(context, {
|
||||
|
|
@ -818,10 +817,10 @@ describe('validateWorkflowConfig', () => {
|
|||
|
||||
it('defaults to allowing message text when the flag is unset (undefined)', async () => {
|
||||
const context = createMockContext(); // no flag in overrides
|
||||
(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
|
||||
makeWorkflow([makeNode({ name: 'HTTP Request' })]),
|
||||
);
|
||||
(context.nodeService.getDescription as jest.Mock).mockResolvedValue(
|
||||
(context.nodeService.getDescription as Mock).mockResolvedValue(
|
||||
makeDescription({ credentials: [] }),
|
||||
);
|
||||
stubLatestRunData(context, {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { MockedFunction } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../../__tests__/tool-test-utils';
|
||||
import type { SandboxWorkspace } from '../../../workspace/sandbox-fs';
|
||||
import { writeFileViaSandbox } from '../../../workspace/sandbox-fs';
|
||||
|
|
@ -8,16 +10,16 @@ import { createWriteSandboxFileTool } from '../write-sandbox-file.tool';
|
|||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
jest.mock('../../../workspace/sandbox-fs', () => ({
|
||||
writeFileViaSandbox: jest.fn(),
|
||||
vi.mock('../../../workspace/sandbox-fs', () => ({
|
||||
writeFileViaSandbox: vi.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../workspace/sandbox-setup', () => ({
|
||||
getWorkspaceRoot: jest.fn(),
|
||||
vi.mock('../../../workspace/sandbox-setup', () => ({
|
||||
getWorkspaceRoot: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWriteFile = writeFileViaSandbox as jest.MockedFunction<typeof writeFileViaSandbox>;
|
||||
const mockGetRoot = getWorkspaceRoot as jest.MockedFunction<typeof getWorkspaceRoot>;
|
||||
const mockWriteFile = writeFileViaSandbox as MockedFunction<typeof writeFileViaSandbox>;
|
||||
const mockGetRoot = getWorkspaceRoot as MockedFunction<typeof getWorkspaceRoot>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -26,7 +28,7 @@ const mockGetRoot = getWorkspaceRoot as jest.MockedFunction<typeof getWorkspaceR
|
|||
function createMockWorkspace(): SandboxWorkspace {
|
||||
return {
|
||||
sandbox: {
|
||||
executeCommand: jest.fn(),
|
||||
executeCommand: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -39,7 +41,7 @@ describe('createWriteSandboxFileTool', () => {
|
|||
let workspace: SandboxWorkspace;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
workspace = createMockWorkspace();
|
||||
mockGetRoot.mockResolvedValue('/home/user/workspace');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
/* eslint-disable import-x/order */
|
||||
import { createRuntimeSkillRegistry, type BuiltTool } from '@n8n/agents';
|
||||
import type { Context, ContextManager } from '@opentelemetry/api';
|
||||
import * as langsmithModule from 'langsmith';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import type * as AsyncHooks from 'node:async_hooks';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import { createToolRegistry } from '../../tool-registry';
|
||||
import { createAskUserTool } from '../../tools/shared/ask-user.tool';
|
||||
import {
|
||||
buildAgentTraceInputs,
|
||||
createDetachedSubAgentTraceContext,
|
||||
createInstanceAiTraceContext,
|
||||
createInternalOperationTraceContext,
|
||||
createTraceReplayOnlyContext,
|
||||
continueInstanceAiTraceContext,
|
||||
mergeTraceRunInputs,
|
||||
redactLangSmithTelemetrySpan,
|
||||
releaseTraceClient,
|
||||
submitLangsmithUserFeedback,
|
||||
withCurrentTraceSpan,
|
||||
} from '../langsmith-tracing';
|
||||
import { TraceWriter, type TraceToolCall, type TraceToolSuspend } from '../trace-replay';
|
||||
|
||||
jest.mock('@n8n/agents', () => {
|
||||
const actual = jest.requireActual<Record<string, unknown>>('@n8n/agents');
|
||||
const { AsyncLocalStorage } = jest.requireActual<typeof AsyncHooks>('node:async_hooks');
|
||||
const { ROOT_CONTEXT, context, trace } = jest.requireActual<{
|
||||
vi.mock('@n8n/agents', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@n8n/agents');
|
||||
const { AsyncLocalStorage } = await vi.importActual<typeof AsyncHooks>('node:async_hooks');
|
||||
const { ROOT_CONTEXT, context, trace } = await vi.importActual<{
|
||||
ROOT_CONTEXT: Context;
|
||||
context: {
|
||||
active(): Context;
|
||||
|
|
@ -118,8 +135,8 @@ jest.mock('@n8n/agents', () => {
|
|||
};
|
||||
|
||||
const provider = {
|
||||
forceFlush: jest.fn(async () => await Promise.resolve()),
|
||||
shutdown: jest.fn(async () => await Promise.resolve()),
|
||||
forceFlush: vi.fn(async () => await Promise.resolve()),
|
||||
shutdown: vi.fn(async () => await Promise.resolve()),
|
||||
};
|
||||
|
||||
class MockLangSmithTelemetry {
|
||||
|
|
@ -198,7 +215,7 @@ jest.mock('@n8n/agents', () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock('langsmith', () => {
|
||||
vi.mock('langsmith', () => {
|
||||
const createFeedbackCalls: Array<{
|
||||
runId: string;
|
||||
key: string;
|
||||
|
|
@ -243,7 +260,7 @@ jest.mock('langsmith', () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock('langsmith/traceable', () => {
|
||||
vi.mock('langsmith/traceable', () => {
|
||||
return {
|
||||
traceable: () => {
|
||||
throw new Error('Instance AI tracing must use OTel spans, not langsmith/traceable');
|
||||
|
|
@ -284,8 +301,8 @@ type AgentsMockModule = {
|
|||
ended: boolean;
|
||||
}>;
|
||||
getProvider: () => {
|
||||
forceFlush: jest.Mock<Promise<void>, []>;
|
||||
shutdown: jest.Mock<Promise<void>, []>;
|
||||
forceFlush: Mock<(...args: []) => Promise<void>>;
|
||||
shutdown: Mock<(...args: []) => Promise<void>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -301,22 +318,6 @@ function isExecutableTool(
|
|||
);
|
||||
}
|
||||
|
||||
const {
|
||||
buildAgentTraceInputs,
|
||||
createDetachedSubAgentTraceContext,
|
||||
createInstanceAiTraceContext,
|
||||
createInternalOperationTraceContext,
|
||||
createTraceReplayOnlyContext,
|
||||
continueInstanceAiTraceContext,
|
||||
mergeTraceRunInputs,
|
||||
redactLangSmithTelemetrySpan,
|
||||
releaseTraceClient,
|
||||
submitLangsmithUserFeedback,
|
||||
withCurrentTraceSpan,
|
||||
} =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../langsmith-tracing') as typeof import('../langsmith-tracing');
|
||||
|
||||
async function startForegroundActor(
|
||||
tracing: NonNullable<Awaited<ReturnType<typeof createInstanceAiTraceContext>>>,
|
||||
) {
|
||||
|
|
@ -335,15 +336,11 @@ async function startForegroundActor(
|
|||
tracing.orchestratorRun = actorRun;
|
||||
return actorRun;
|
||||
}
|
||||
const { createAskUserTool } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../../tools/shared/ask-user.tool') as typeof import('../../tools/shared/ask-user.tool');
|
||||
const { __mock: langsmithMock } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('langsmith') as LangSmithMockModule;
|
||||
const { __mock: agentsMock } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('@n8n/agents') as AgentsMockModule;
|
||||
|
||||
import * as agentsModule from '@n8n/agents';
|
||||
|
||||
const { __mock: langsmithMock } = langsmithModule as unknown as LangSmithMockModule;
|
||||
const { __mock: agentsMock } = agentsModule as unknown as AgentsMockModule;
|
||||
|
||||
describe('createInstanceAiTraceContext', () => {
|
||||
const originalLangSmithApiKey = process.env.LANGSMITH_API_KEY;
|
||||
|
|
@ -1575,12 +1572,12 @@ describe('createInstanceAiTraceContext', () => {
|
|||
const regularTool = {
|
||||
name: 'templates',
|
||||
description: 'Search templates',
|
||||
handler: jest.fn(),
|
||||
handler: vi.fn(),
|
||||
};
|
||||
const workspaceTool = {
|
||||
name: 'workspace_execute_command',
|
||||
description: 'Run a workspace command',
|
||||
handler: jest.fn(),
|
||||
handler: vi.fn(),
|
||||
};
|
||||
|
||||
const wrappedTools = tracing!.wrapTools(
|
||||
|
|
@ -1928,7 +1925,7 @@ describe('createInstanceAiTraceContext', () => {
|
|||
{
|
||||
name: 'workspace_write_file',
|
||||
description: 'Write a file in the workspace.',
|
||||
handler: jest.fn(async () => await Promise.resolve({ written: true })),
|
||||
handler: vi.fn(async () => await Promise.resolve({ written: true })),
|
||||
} as never,
|
||||
],
|
||||
]),
|
||||
|
|
@ -2115,7 +2112,7 @@ describe('submitLangsmithUserFeedback', () => {
|
|||
});
|
||||
|
||||
it('routes through the proxy client when proxyConfig is provided', async () => {
|
||||
const getAuthHeaders = jest.fn().mockResolvedValue({ Authorization: 'Bearer token' });
|
||||
const getAuthHeaders = vi.fn().mockResolvedValue({ Authorization: 'Bearer token' });
|
||||
await submitLangsmithUserFeedback({
|
||||
langsmithRunId: 'ls-run-3',
|
||||
langsmithTraceId: 'ls-trace-3',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
jest.mock('@n8n/workflow-sdk', () => ({
|
||||
parseWorkflowCodeToBuilder: jest.fn(),
|
||||
validateWorkflow: jest.fn(),
|
||||
vi.mock('@n8n/workflow-sdk', () => ({
|
||||
parseWorkflowCodeToBuilder: vi.fn(),
|
||||
validateWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../extract-code', () => ({
|
||||
stripImportStatements: jest.fn((code: string) => code),
|
||||
vi.mock('../extract-code', () => ({
|
||||
stripImportStatements: vi.fn((code: string) => code),
|
||||
}));
|
||||
|
||||
import { parseWorkflowCodeToBuilder, validateWorkflow } from '@n8n/workflow-sdk';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INodeTypes } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { stripImportStatements } from '../extract-code';
|
||||
import { parseAndValidate, partitionWarnings } from '../parse-validate';
|
||||
|
||||
const mockedParseWorkflowCodeToBuilder = jest.mocked(parseWorkflowCodeToBuilder);
|
||||
const mockedValidateWorkflow = jest.mocked(validateWorkflow);
|
||||
const mockedStripImportStatements = jest.mocked(stripImportStatements);
|
||||
const mockedParseWorkflowCodeToBuilder = vi.mocked(parseWorkflowCodeToBuilder);
|
||||
const mockedValidateWorkflow = vi.mocked(validateWorkflow);
|
||||
const mockedStripImportStatements = vi.mocked(stripImportStatements);
|
||||
|
||||
function makeBuilder(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({ errors: [], warnings: [] }),
|
||||
toJSON: jest.fn().mockReturnValue({ name: 'Test', nodes: [], connections: {} }),
|
||||
regenerateNodeIds: vi.fn(),
|
||||
validate: vi.fn().mockReturnValue({ errors: [], warnings: [] }),
|
||||
toJSON: vi.fn().mockReturnValue({ name: 'Test', nodes: [], connections: {} }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseAndValidate', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockedStripImportStatements.mockImplementation((code) => code);
|
||||
mockedValidateWorkflow.mockReturnValue({ errors: [], warnings: [] } as never);
|
||||
});
|
||||
|
|
@ -52,7 +52,7 @@ describe('parseAndValidate', () => {
|
|||
|
||||
it('collects graph validation errors and warnings', () => {
|
||||
const builder = makeBuilder({
|
||||
validate: jest.fn().mockReturnValue({
|
||||
validate: vi.fn().mockReturnValue({
|
||||
errors: [{ code: 'GRAPH_ERROR', message: 'Cycle detected' }],
|
||||
warnings: [{ code: 'MISSING_TRIGGER', message: 'No trigger found' }],
|
||||
}),
|
||||
|
|
@ -85,7 +85,7 @@ describe('parseAndValidate', () => {
|
|||
|
||||
it('combines graph and schema validation issues', () => {
|
||||
const builder = makeBuilder({
|
||||
validate: jest.fn().mockReturnValue({
|
||||
validate: vi.fn().mockReturnValue({
|
||||
errors: [{ code: 'E1', message: 'graph error' }],
|
||||
warnings: [],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ function createStorage() {
|
|||
const records = new Map<string, Record<string, unknown>>();
|
||||
|
||||
const storage = {
|
||||
getWorkItem: jest.fn(async (_threadId: string, workItemId: string) => {
|
||||
getWorkItem: vi.fn(async (_threadId: string, workItemId: string) => {
|
||||
return await Promise.resolve(
|
||||
(records.get(workItemId) ?? null) as Awaited<
|
||||
ReturnType<WorkflowLoopStorage['getWorkItem']>
|
||||
>,
|
||||
);
|
||||
}),
|
||||
saveWorkItem: jest.fn(
|
||||
saveWorkItem: vi.fn(
|
||||
async (
|
||||
_threadId: string,
|
||||
state: Record<string, unknown>,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|||
import * as fsp from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import {
|
||||
BuilderTemplatesService,
|
||||
|
|
@ -55,8 +56,8 @@ function isLatestArchiveUrl(url: string): boolean {
|
|||
return url.endsWith('/latest/templates.tar.gz');
|
||||
}
|
||||
|
||||
function installMockFetch(state: MockState): jest.Mock {
|
||||
const mock = jest.fn((input: string | URL | Request, init?: RequestInit) => {
|
||||
function installMockFetch(state: MockState): Mock {
|
||||
const mock = vi.fn((input: string | URL | Request, init?: RequestInit) => {
|
||||
state.calls.fetch++;
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const headers = new Headers((init?.headers ?? {}) as Record<string, string>);
|
||||
|
|
@ -194,7 +195,7 @@ describe('BuilderTemplatesService', () => {
|
|||
const state = makeState();
|
||||
state.archiveStatus = 500;
|
||||
installMockFetch(state);
|
||||
const dateNow = jest.spyOn(Date, 'now');
|
||||
const dateNow = vi.spyOn(Date, 'now');
|
||||
dateNow.mockReturnValue(1_000);
|
||||
|
||||
try {
|
||||
|
|
@ -272,7 +273,7 @@ describe('BuilderTemplatesService', () => {
|
|||
await fsp.writeFile(path.join(cacheDir, 'channel.txt'), 'exact');
|
||||
|
||||
// Block any network call so we know hydration came from disk.
|
||||
globalThis.fetch = jest.fn(
|
||||
globalThis.fetch = vi.fn(
|
||||
() => new Response('', { status: 500 }),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
|
||||
|
|
@ -423,7 +424,7 @@ describe('BuilderTemplatesService', () => {
|
|||
const cacheDir = await makeTempDir();
|
||||
const state = makeState();
|
||||
state.sha256Override = null; // sidecar 404
|
||||
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() };
|
||||
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
installMockFetch(state);
|
||||
|
||||
const svc = new BuilderTemplatesService(makeOptions(cacheDir, { logger }));
|
||||
|
|
@ -547,7 +548,7 @@ describe('BuilderTemplatesService', () => {
|
|||
await fsp.writeFile(path.join(cacheDir, 'templates.tar.gz.sha256'), sha256Hex(archive));
|
||||
await fsp.writeFile(path.join(cacheDir, 'channel.txt'), 'latest');
|
||||
|
||||
globalThis.fetch = jest.fn(
|
||||
globalThis.fetch = vi.fn(
|
||||
() => new Response('', { status: 500 }),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
|
||||
|
|
@ -564,7 +565,7 @@ describe('BuilderTemplatesService', () => {
|
|||
const cacheDir = await makeTempDir();
|
||||
const state = makeState();
|
||||
state.exactStatus = 404;
|
||||
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() };
|
||||
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
installMockFetch(state);
|
||||
|
||||
const svc = new BuilderTemplatesService(
|
||||
|
|
@ -618,7 +619,7 @@ describe('builderTemplatesOptionsFromEnv', () => {
|
|||
it('omits refreshIntervalMs and warns when refresh hours is not a number', () => {
|
||||
clearEnv();
|
||||
process.env.N8N_INSTANCE_AI_TEMPLATES_REFRESH_HOURS = 'banana';
|
||||
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() };
|
||||
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
const opts = builderTemplatesOptionsFromEnv({ logger });
|
||||
expect(opts.refreshIntervalMs).toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
const daytonaInstances: Array<{ config: unknown }> = [];
|
||||
const { daytonaInstances } = vi.hoisted(() => ({
|
||||
daytonaInstances: [] as Array<{ config: unknown }>,
|
||||
}));
|
||||
|
||||
jest.mock('@daytonaio/sdk', () => {
|
||||
vi.mock('../lazy-daytona', () => {
|
||||
class Daytona {
|
||||
constructor(public config: unknown) {
|
||||
daytonaInstances.push({ config });
|
||||
}
|
||||
}
|
||||
return { Daytona };
|
||||
return { loadDaytona: () => ({ Daytona }) };
|
||||
});
|
||||
|
||||
import { DaytonaAuthManager } from '../daytona-auth-manager';
|
||||
|
|
@ -67,7 +69,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
|
|||
|
||||
it('fetches a token on first call and decodes its exp', async () => {
|
||||
const token = makeJwt(now + HOUR_MS);
|
||||
const getAuthToken = jest.fn().mockResolvedValue(token);
|
||||
const getAuthToken = vi.fn().mockResolvedValue(token);
|
||||
const manager = new DaytonaAuthManager({ getAuthToken, now: nowFn });
|
||||
|
||||
await manager.getClient();
|
||||
|
|
@ -79,7 +81,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
|
|||
});
|
||||
|
||||
it('reuses the cached client when well outside the skew window', async () => {
|
||||
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(now + HOUR_MS));
|
||||
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(now + HOUR_MS));
|
||||
const manager = new DaytonaAuthManager({ getAuthToken, now: nowFn });
|
||||
|
||||
await manager.getClient();
|
||||
|
|
@ -92,7 +94,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
|
|||
});
|
||||
|
||||
it('refreshes when the next call is inside the skew window', async () => {
|
||||
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
|
||||
const getAuthToken = vi.fn<(...args: []) => Promise<string>>().mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return makeJwt(nowFn() + HOUR_MS);
|
||||
});
|
||||
|
|
@ -110,7 +112,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
|
|||
|
||||
it('serializes concurrent refreshes (single-flight)', async () => {
|
||||
let resolveToken: (token: string) => void = () => {};
|
||||
const getAuthToken = jest.fn(
|
||||
const getAuthToken = vi.fn(
|
||||
async () =>
|
||||
await new Promise<string>((resolve) => {
|
||||
resolveToken = resolve;
|
||||
|
|
@ -130,7 +132,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
|
|||
});
|
||||
|
||||
it('falls back to 30-minute TTL when the token is opaque', async () => {
|
||||
const getAuthToken = jest.fn().mockResolvedValue('opaque-token');
|
||||
const getAuthToken = vi.fn().mockResolvedValue('opaque-token');
|
||||
const manager = new DaytonaAuthManager({ getAuthToken, now: nowFn });
|
||||
|
||||
await manager.getClient();
|
||||
|
|
@ -147,7 +149,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
|
|||
|
||||
it('uses a configurable refresh skew', async () => {
|
||||
const customSkewMs = 15 * MINUTE_MS;
|
||||
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
|
||||
const getAuthToken = vi.fn<(...args: []) => Promise<string>>().mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return makeJwt(nowFn() + HOUR_MS);
|
||||
});
|
||||
|
|
@ -171,7 +173,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
|
|||
});
|
||||
|
||||
it('ignores non-positive refreshSkewMs and falls back to the default', async () => {
|
||||
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(now + HOUR_MS));
|
||||
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(now + HOUR_MS));
|
||||
const manager = new DaytonaAuthManager({
|
||||
getAuthToken,
|
||||
refreshSkewMs: 0,
|
||||
|
|
@ -186,7 +188,7 @@ describe('DaytonaAuthManager (proxy mode)', () => {
|
|||
});
|
||||
|
||||
it('passes apiUrl through to the Daytona client', async () => {
|
||||
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
|
||||
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
|
||||
const manager = new DaytonaAuthManager({
|
||||
getAuthToken,
|
||||
apiUrl: 'https://proxy.example.com',
|
||||
|
|
@ -206,7 +208,7 @@ describe('DaytonaAuthManager (invariants)', () => {
|
|||
});
|
||||
|
||||
it('rejects construction with both auth options', () => {
|
||||
const getAuthToken = jest.fn().mockResolvedValue('jwt');
|
||||
const getAuthToken = vi.fn().mockResolvedValue('jwt');
|
||||
expect(
|
||||
() =>
|
||||
new DaytonaAuthManager({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
// Mock @daytonaio/sdk so we can drive sandbox creation, token refresh, and
|
||||
// sandbox refetch behavior from Jest without touching the network.
|
||||
|
||||
|
|
@ -7,86 +9,101 @@ interface MockSandbox {
|
|||
memory: number;
|
||||
target: string;
|
||||
state: string;
|
||||
process: { executeCommand: jest.Mock };
|
||||
fs: Record<string, jest.Mock>;
|
||||
start: jest.Mock;
|
||||
stop: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
getWorkDir: jest.Mock;
|
||||
}
|
||||
|
||||
function makeMockSandbox(id: string, state = 'started'): MockSandbox {
|
||||
return {
|
||||
id,
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
target: 'us',
|
||||
state,
|
||||
process: {
|
||||
executeCommand: jest.fn().mockResolvedValue({
|
||||
exitCode: 0,
|
||||
artifacts: { stdout: 'ok' },
|
||||
result: 'ok',
|
||||
}),
|
||||
},
|
||||
fs: {
|
||||
downloadFile: jest.fn(),
|
||||
uploadFile: jest.fn(),
|
||||
deleteFile: jest.fn(),
|
||||
createFolder: jest.fn(),
|
||||
listFiles: jest.fn(),
|
||||
getFileDetails: jest.fn(),
|
||||
moveFiles: jest.fn(),
|
||||
},
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
getWorkDir: jest.fn().mockResolvedValue('/home/daytona/workspace'),
|
||||
};
|
||||
process: { executeCommand: Mock };
|
||||
fs: Record<string, Mock>;
|
||||
start: Mock;
|
||||
stop: Mock;
|
||||
delete: Mock;
|
||||
getWorkDir: Mock;
|
||||
}
|
||||
|
||||
interface DaytonaClientLog {
|
||||
id: number;
|
||||
config: unknown;
|
||||
get: jest.Mock<Promise<MockSandbox>, [string]>;
|
||||
create: jest.Mock<Promise<MockSandbox>, [unknown, unknown?]>;
|
||||
delete: jest.Mock;
|
||||
get: Mock<(...args: [string]) => Promise<MockSandbox>>;
|
||||
create: Mock<(...args: [unknown, unknown?]) => Promise<MockSandbox>>;
|
||||
delete: Mock;
|
||||
}
|
||||
|
||||
const clientLog: DaytonaClientLog[] = [];
|
||||
let nextClientId = 1;
|
||||
let nextSandboxId = 1;
|
||||
const queuedGetErrors: Error[] = [];
|
||||
const queuedCreateResults: Array<MockSandbox | Error> = [];
|
||||
// All mock state, helpers, and SDK classes live inside vi.hoisted so they are
|
||||
// initialized before the (hoisted) module imports run. The Daytona SDK is
|
||||
// consumed in source via `loadDaytona()` (which `require()`s @daytonaio/sdk), so
|
||||
// we mock the first-party `lazy-daytona` module rather than the package itself.
|
||||
const {
|
||||
clientLog,
|
||||
queuedGetErrors,
|
||||
queuedCreateResults,
|
||||
makeMockSandbox,
|
||||
Daytona,
|
||||
DaytonaError,
|
||||
DaytonaNotFoundError,
|
||||
resetDaytonaMockState,
|
||||
} = vi.hoisted(() => {
|
||||
const clientLog: DaytonaClientLog[] = [];
|
||||
let nextClientId = 1;
|
||||
let nextSandboxId = 1;
|
||||
const queuedGetErrors: Error[] = [];
|
||||
const queuedCreateResults: Array<MockSandbox | Error> = [];
|
||||
|
||||
// Each client's get() returns a NEW sandbox object so the test can detect
|
||||
// refetch (i.e. .process / .fs identity changes after rotation).
|
||||
function makeDaytonaClientForLog(config: unknown): DaytonaClientLog {
|
||||
const id = nextClientId++;
|
||||
const get = jest.fn<Promise<MockSandbox>, [string]>().mockImplementation(async () => {
|
||||
const queued = queuedGetErrors.shift();
|
||||
if (queued !== undefined) {
|
||||
return await Promise.reject(queued);
|
||||
}
|
||||
return await Promise.resolve(makeMockSandbox(`sb-${id}-${nextSandboxId++}`));
|
||||
});
|
||||
const create = jest
|
||||
.fn<Promise<MockSandbox>, [unknown, unknown?]>()
|
||||
.mockImplementation(async () => {
|
||||
const queued = queuedCreateResults.shift();
|
||||
if (queued instanceof Error) {
|
||||
return await Promise.reject(queued);
|
||||
}
|
||||
if (queued) return await Promise.resolve(queued);
|
||||
return await Promise.resolve(makeMockSandbox(`sb-create-${id}-${nextSandboxId++}`));
|
||||
});
|
||||
const del = jest.fn().mockResolvedValue(undefined);
|
||||
const log: DaytonaClientLog = { id, config, get, create, delete: del };
|
||||
clientLog.push(log);
|
||||
return log;
|
||||
}
|
||||
function makeMockSandbox(id: string, state = 'started'): MockSandbox {
|
||||
return {
|
||||
id,
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
target: 'us',
|
||||
state,
|
||||
process: {
|
||||
executeCommand: vi.fn().mockResolvedValue({
|
||||
exitCode: 0,
|
||||
artifacts: { stdout: 'ok' },
|
||||
result: 'ok',
|
||||
}),
|
||||
},
|
||||
fs: {
|
||||
downloadFile: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
getFileDetails: vi.fn(),
|
||||
moveFiles: vi.fn(),
|
||||
},
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getWorkDir: vi.fn().mockResolvedValue('/home/daytona/workspace'),
|
||||
};
|
||||
}
|
||||
|
||||
// Each client's get() returns a NEW sandbox object so the test can detect
|
||||
// refetch (i.e. .process / .fs identity changes after rotation).
|
||||
function makeDaytonaClientForLog(config: unknown): DaytonaClientLog {
|
||||
const id = nextClientId++;
|
||||
const get = vi
|
||||
.fn<(...args: [string]) => Promise<MockSandbox>>()
|
||||
.mockImplementation(async () => {
|
||||
const queued = queuedGetErrors.shift();
|
||||
if (queued !== undefined) {
|
||||
return await Promise.reject(queued);
|
||||
}
|
||||
return await Promise.resolve(makeMockSandbox(`sb-${id}-${nextSandboxId++}`));
|
||||
});
|
||||
const create = vi
|
||||
.fn<(...args: [unknown, unknown?]) => Promise<MockSandbox>>()
|
||||
.mockImplementation(async () => {
|
||||
const queued = queuedCreateResults.shift();
|
||||
if (queued instanceof Error) {
|
||||
return await Promise.reject(queued);
|
||||
}
|
||||
if (queued) return await Promise.resolve(queued);
|
||||
return await Promise.resolve(makeMockSandbox(`sb-create-${id}-${nextSandboxId++}`));
|
||||
});
|
||||
const del = vi.fn().mockResolvedValue(undefined);
|
||||
const log: DaytonaClientLog = { id, config, get, create, delete: del };
|
||||
clientLog.push(log);
|
||||
return log;
|
||||
}
|
||||
|
||||
jest.mock('@daytonaio/sdk', () => {
|
||||
class Daytona {
|
||||
private readonly log: DaytonaClientLog;
|
||||
constructor(config: unknown) {
|
||||
|
|
@ -114,10 +131,30 @@ jest.mock('@daytonaio/sdk', () => {
|
|||
super(message, 404);
|
||||
}
|
||||
}
|
||||
return { Daytona, DaytonaError, DaytonaNotFoundError };
|
||||
|
||||
function resetDaytonaMockState(): void {
|
||||
clientLog.length = 0;
|
||||
nextClientId = 1;
|
||||
nextSandboxId = 1;
|
||||
queuedGetErrors.length = 0;
|
||||
queuedCreateResults.length = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
clientLog,
|
||||
queuedGetErrors,
|
||||
queuedCreateResults,
|
||||
makeMockSandbox,
|
||||
Daytona,
|
||||
DaytonaError,
|
||||
DaytonaNotFoundError,
|
||||
resetDaytonaMockState,
|
||||
};
|
||||
});
|
||||
|
||||
import type * as DaytonaSdk from '@daytonaio/sdk';
|
||||
vi.mock('../lazy-daytona', () => ({
|
||||
loadDaytona: () => ({ Daytona, DaytonaError, DaytonaNotFoundError }),
|
||||
}));
|
||||
|
||||
import type { ErrorReporter, Logger } from '../../logger';
|
||||
import { DaytonaSandbox } from '../daytona-sandbox';
|
||||
|
|
@ -132,16 +169,15 @@ function makeJwt(expMs: number): string {
|
|||
}
|
||||
|
||||
function queueNotFound(message = 'sandbox not found'): void {
|
||||
const sdkMock = jest.requireMock<typeof DaytonaSdk>('@daytonaio/sdk');
|
||||
queuedGetErrors.push(new sdkMock.DaytonaNotFoundError(message));
|
||||
queuedGetErrors.push(new DaytonaNotFoundError(message));
|
||||
}
|
||||
|
||||
function makeLogger(): Logger {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -150,17 +186,13 @@ const MINUTE_MS = 60 * 1000;
|
|||
const SKEW_MS = 5 * MINUTE_MS;
|
||||
|
||||
beforeEach(() => {
|
||||
clientLog.length = 0;
|
||||
nextClientId = 1;
|
||||
nextSandboxId = 1;
|
||||
queuedGetErrors.length = 0;
|
||||
queuedCreateResults.length = 0;
|
||||
resetDaytonaMockState();
|
||||
});
|
||||
|
||||
describe('DaytonaSandbox (creation strategies)', () => {
|
||||
it('falls back from snapshot creation to image creation and preserves sandbox labels', async () => {
|
||||
const logger = makeLogger();
|
||||
const errorReporter: ErrorReporter = { error: jest.fn() };
|
||||
const errorReporter: ErrorReporter = { error: vi.fn() };
|
||||
const snapshotError = new Error('snapshot missing');
|
||||
queueNotFound('not found');
|
||||
queuedCreateResults.push(snapshotError, makeMockSandbox('remote-sandbox'));
|
||||
|
|
@ -232,7 +264,7 @@ describe('DaytonaSandbox (creation strategies)', () => {
|
|||
});
|
||||
|
||||
it('reports image strategy failures and rethrows', async () => {
|
||||
const errorReporter: ErrorReporter = { error: jest.fn() };
|
||||
const errorReporter: ErrorReporter = { error: vi.fn() };
|
||||
const imageError = new Error('image create failed');
|
||||
queueNotFound('not found');
|
||||
queuedCreateResults.push(imageError);
|
||||
|
|
@ -279,7 +311,7 @@ describe('DaytonaSandbox (direct mode)', () => {
|
|||
|
||||
describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
|
||||
it('mints a Daytona client only when the sandbox is first touched', () => {
|
||||
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
|
||||
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
|
||||
new DaytonaSandbox({ name: 'thread-1', getAuthToken });
|
||||
|
||||
expect(getAuthToken).not.toHaveBeenCalled();
|
||||
|
|
@ -287,7 +319,7 @@ describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
|
|||
});
|
||||
|
||||
it('reuses the same Daytona client across calls within the TTL window', async () => {
|
||||
const getAuthToken = jest.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
|
||||
const getAuthToken = vi.fn().mockResolvedValue(makeJwt(Date.now() + HOUR_MS));
|
||||
const sandbox = new DaytonaSandbox({ name: 'thread-1', getAuthToken });
|
||||
|
||||
await sandbox.start();
|
||||
|
|
@ -299,12 +331,14 @@ describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
|
|||
});
|
||||
|
||||
it('refetches the Sandbox via client.get() after the JWT rotates', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
|
||||
vi.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
|
||||
try {
|
||||
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return makeJwt(Date.now() + HOUR_MS);
|
||||
});
|
||||
const getAuthToken = vi
|
||||
.fn<(...args: []) => Promise<string>>()
|
||||
.mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return makeJwt(Date.now() + HOUR_MS);
|
||||
});
|
||||
const sandbox = new DaytonaSandbox({ name: 'thread-1', getAuthToken });
|
||||
|
||||
await sandbox.start();
|
||||
|
|
@ -313,7 +347,7 @@ describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
|
|||
expect(clientLog).toHaveLength(1);
|
||||
|
||||
// Advance into the skew window.
|
||||
jest.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
|
||||
vi.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
|
||||
|
||||
await sandbox.executeCommand('echo', ['after-refresh']);
|
||||
|
||||
|
|
@ -326,29 +360,31 @@ describe('DaytonaSandbox (proxy mode - JWT refresh)', () => {
|
|||
expect(sandbox.instance.process).not.toBe(firstProcess);
|
||||
expect(sandbox.instance.process.executeCommand).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes on ensureAuthFresh() before fs operations', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
|
||||
vi.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
|
||||
try {
|
||||
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return makeJwt(Date.now() + HOUR_MS);
|
||||
});
|
||||
const getAuthToken = vi
|
||||
.fn<(...args: []) => Promise<string>>()
|
||||
.mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return makeJwt(Date.now() + HOUR_MS);
|
||||
});
|
||||
const sandbox = new DaytonaSandbox({ name: 'thread-1', getAuthToken });
|
||||
|
||||
await sandbox.start();
|
||||
expect(getAuthToken).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
|
||||
vi.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
|
||||
await sandbox.ensureAuthFresh();
|
||||
|
||||
expect(getAuthToken).toHaveBeenCalledTimes(2);
|
||||
expect(clientLog).toHaveLength(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -359,22 +395,22 @@ describe('DaytonaSandbox (remote sandbox gone during refetch)', () => {
|
|||
// call into the sandbox triggers a token rotation; the refetch then surfaces
|
||||
// the remote-gone condition.
|
||||
async function startAndStageRemoteGone() {
|
||||
jest.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
|
||||
const getAuthToken = jest.fn<Promise<string>, []>().mockImplementation(async () => {
|
||||
vi.useFakeTimers().setSystemTime(new Date(1_700_000_000_000));
|
||||
const getAuthToken = vi.fn<(...args: []) => Promise<string>>().mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return makeJwt(Date.now() + HOUR_MS);
|
||||
});
|
||||
const sandbox = new DaytonaSandbox({ name: 'thread-1', getAuthToken });
|
||||
|
||||
await sandbox.start();
|
||||
jest.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
|
||||
vi.setSystemTime(new Date(Date.now() + HOUR_MS - SKEW_MS + 1));
|
||||
queueNotFound();
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('stop() treats remote NotFound as idempotent and clears the cache', async () => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ import {
|
|||
import { createLazyRuntimeWorkspace } from '../lazy-runtime-workspace';
|
||||
|
||||
function createMockWorkspace() {
|
||||
const executeCommand = jest.fn<
|
||||
Promise<CommandResult>,
|
||||
Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>
|
||||
const executeCommand = vi.fn<
|
||||
(...args: Parameters<NonNullable<WorkspaceSandbox['executeCommand']>>) => Promise<CommandResult>
|
||||
>(
|
||||
async (_command, _args, options) =>
|
||||
await Promise.resolve({
|
||||
|
|
@ -26,22 +25,22 @@ function createMockWorkspace() {
|
|||
name: 'Filesystem',
|
||||
provider: 'test',
|
||||
status: 'ready',
|
||||
destroy: jest.fn(async () => {
|
||||
destroy: vi.fn(async () => {
|
||||
filesystem.status = 'destroyed';
|
||||
await Promise.resolve();
|
||||
}),
|
||||
getInstructions: jest.fn(() => 'Real filesystem instructions.'),
|
||||
readFile: jest.fn(async () => await Promise.resolve('hello')),
|
||||
writeFile: jest.fn(async () => await Promise.resolve()),
|
||||
appendFile: jest.fn(async () => await Promise.resolve()),
|
||||
deleteFile: jest.fn(async () => await Promise.resolve()),
|
||||
copyFile: jest.fn(async () => await Promise.resolve()),
|
||||
moveFile: jest.fn(async () => await Promise.resolve()),
|
||||
mkdir: jest.fn(async () => await Promise.resolve()),
|
||||
rmdir: jest.fn(async () => await Promise.resolve()),
|
||||
readdir: jest.fn(async () => await Promise.resolve([])),
|
||||
exists: jest.fn(async () => await Promise.resolve(true)),
|
||||
stat: jest.fn(
|
||||
getInstructions: vi.fn(() => 'Real filesystem instructions.'),
|
||||
readFile: vi.fn(async () => await Promise.resolve('hello')),
|
||||
writeFile: vi.fn(async () => await Promise.resolve()),
|
||||
appendFile: vi.fn(async () => await Promise.resolve()),
|
||||
deleteFile: vi.fn(async () => await Promise.resolve()),
|
||||
copyFile: vi.fn(async () => await Promise.resolve()),
|
||||
moveFile: vi.fn(async () => await Promise.resolve()),
|
||||
mkdir: vi.fn(async () => await Promise.resolve()),
|
||||
rmdir: vi.fn(async () => await Promise.resolve()),
|
||||
readdir: vi.fn(async () => await Promise.resolve([])),
|
||||
exists: vi.fn(async () => await Promise.resolve(true)),
|
||||
stat: vi.fn(
|
||||
async (path: string) =>
|
||||
await Promise.resolve({
|
||||
name: path,
|
||||
|
|
@ -58,16 +57,16 @@ function createMockWorkspace() {
|
|||
name: 'Sandbox',
|
||||
provider: 'test',
|
||||
status: 'running',
|
||||
stop: jest.fn(async () => {
|
||||
stop: vi.fn(async () => {
|
||||
sandbox.status = 'stopped';
|
||||
await Promise.resolve();
|
||||
}),
|
||||
destroy: jest.fn(async () => {
|
||||
destroy: vi.fn(async () => {
|
||||
sandbox.status = 'destroyed';
|
||||
await Promise.resolve();
|
||||
}),
|
||||
getInstructions: jest.fn(() => 'Real sandbox instructions.'),
|
||||
getDefaultCommandEnv: jest.fn(() => ({ CUSTOM_ENV: 'enabled' })),
|
||||
getInstructions: vi.fn(() => 'Real sandbox instructions.'),
|
||||
getDefaultCommandEnv: vi.fn(() => ({ CUSTOM_ENV: 'enabled' })),
|
||||
executeCommand,
|
||||
};
|
||||
|
||||
|
|
@ -82,7 +81,7 @@ function createMockWorkspace() {
|
|||
describe('createLazyRuntimeWorkspace', () => {
|
||||
it('advertises workspace tools without creating the real workspace', async () => {
|
||||
const { workspace } = createMockWorkspace();
|
||||
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
|
||||
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
|
||||
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
|
||||
|
||||
const tools = lazyWorkspace.getTools();
|
||||
|
|
@ -100,7 +99,7 @@ describe('createLazyRuntimeWorkspace', () => {
|
|||
|
||||
it('merges sandbox default env after the real workspace is created', async () => {
|
||||
const { workspace, executeCommand } = createMockWorkspace();
|
||||
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
|
||||
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
|
||||
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
|
||||
const executeCommandTool = lazyWorkspace
|
||||
.getTools()
|
||||
|
|
@ -118,7 +117,7 @@ describe('createLazyRuntimeWorkspace', () => {
|
|||
|
||||
it('retries workspace creation after the first lazy initialization fails', async () => {
|
||||
const { workspace } = createMockWorkspace();
|
||||
const ensureWorkspace = jest
|
||||
const ensureWorkspace = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('setup failed'))
|
||||
.mockResolvedValueOnce(workspace);
|
||||
|
|
@ -137,7 +136,7 @@ describe('createLazyRuntimeWorkspace', () => {
|
|||
|
||||
it('retries workspace creation after the first lazy initialization returns unavailable', async () => {
|
||||
const { workspace } = createMockWorkspace();
|
||||
const ensureWorkspace = jest
|
||||
const ensureWorkspace = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(workspace);
|
||||
|
|
@ -156,7 +155,7 @@ describe('createLazyRuntimeWorkspace', () => {
|
|||
|
||||
it('reflects resolved provider statuses and instructions', async () => {
|
||||
const { workspace } = createMockWorkspace();
|
||||
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
|
||||
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
|
||||
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
|
||||
|
||||
expect(lazyWorkspace.filesystem?.status).toBe('pending');
|
||||
|
|
@ -173,7 +172,7 @@ describe('createLazyRuntimeWorkspace', () => {
|
|||
|
||||
it('destroys the resolved workspace when the lazy workspace is destroyed', async () => {
|
||||
const { workspace, filesystem, sandbox } = createMockWorkspace();
|
||||
const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace));
|
||||
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
|
||||
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
|
||||
|
||||
await lazyWorkspace.filesystem?.readFile('/workspace/report.md');
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { N8nSandboxServiceSandbox } from '../n8n-sandbox-sandbox';
|
|||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockCreateSandbox = jest.fn();
|
||||
const mockGetSandbox = jest.fn();
|
||||
const mockDeleteSandbox = jest.fn();
|
||||
const mockExec = jest.fn();
|
||||
const mockCreateSandbox = vi.fn();
|
||||
const mockGetSandbox = vi.fn();
|
||||
const mockDeleteSandbox = vi.fn();
|
||||
const mockExec = vi.fn();
|
||||
|
||||
jest.mock('@n8n/sandbox-client', () => {
|
||||
vi.mock('@n8n/sandbox-client', () => {
|
||||
class MockSandboxServiceError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ function makeExecResult(overrides: Record<string, unknown> = {}) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockCreateSandbox.mockResolvedValue(makeSandboxRecord());
|
||||
mockGetSandbox.mockResolvedValue(makeSandboxRecord());
|
||||
mockDeleteSandbox.mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ function createSandboxWorkspace(files: Map<string, string>): {
|
|||
const workspace: SandboxWorkspace = {
|
||||
filesystem: {
|
||||
provider: 'local',
|
||||
writeFile: jest.fn(async (path: string, content: string | Buffer) => {
|
||||
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
|
||||
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
|
||||
await Promise.resolve();
|
||||
}),
|
||||
mkdir: jest.fn(async () => await Promise.resolve()),
|
||||
mkdir: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
sandbox: {
|
||||
executeCommand: jest.fn(async (command: string) => {
|
||||
executeCommand: vi.fn(async (command: string) => {
|
||||
const readMatch = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
|
||||
if (readMatch) {
|
||||
const content = files.get(readMatch[1]);
|
||||
|
|
@ -166,12 +166,12 @@ describe('materializeWorkspaceBundle', () => {
|
|||
const workspace: SandboxWorkspace = {
|
||||
filesystem: {
|
||||
provider: 'local',
|
||||
writeFile: jest.fn(async (path: string, content: string | Buffer) => {
|
||||
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
|
||||
writeOrder.push(path);
|
||||
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
|
||||
await Promise.resolve();
|
||||
}),
|
||||
mkdir: jest.fn(async () => await Promise.resolve()),
|
||||
mkdir: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { jsonParse } from 'n8n-workflow';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { InstanceAiContext, SearchableNodeDescription } from '../../types';
|
||||
import type { BuilderTemplatesBundle } from '../builder-templates-service';
|
||||
|
|
@ -11,30 +12,31 @@ type SetupSandboxWorkspace = typeof setupSandboxWorkspaceFunction;
|
|||
type LinkWorkspaceSdkIfEnabled = (
|
||||
workspace: SandboxWorkspace,
|
||||
root: string,
|
||||
logger?: { error: jest.Mock; info: jest.Mock },
|
||||
logger?: { error: Mock; info: Mock },
|
||||
) => Promise<void>;
|
||||
type RunInSandboxMock = jest.Mock<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
type RunInSandboxMock = Mock<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>;
|
||||
type ReadFileViaSandboxMock = jest.Mock<Promise<string | null>, [SandboxWorkspace, string]>;
|
||||
type ReadFileViaSandboxMock = Mock<(...args: [SandboxWorkspace, string]) => Promise<string | null>>;
|
||||
|
||||
function createSetupContext(
|
||||
templatesBundle: BuilderTemplatesBundle | null = null,
|
||||
): InstanceAiContext {
|
||||
return {
|
||||
nodeService: {
|
||||
listSearchable: jest.fn().mockResolvedValue([]),
|
||||
listSearchable: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
workflowService: {
|
||||
list: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
},
|
||||
...(templatesBundle
|
||||
? {
|
||||
templatesService: {
|
||||
getBundle: jest.fn().mockResolvedValue(templatesBundle),
|
||||
getVersion: jest.fn().mockReturnValue(templatesBundle.version),
|
||||
getBundle: vi.fn().mockResolvedValue(templatesBundle),
|
||||
getVersion: vi.fn().mockReturnValue(templatesBundle.version),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
@ -42,25 +44,27 @@ function createSetupContext(
|
|||
}
|
||||
|
||||
function createLocalWorkspace(
|
||||
writeFile: jest.Mock<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>,
|
||||
mkdir?: jest.Mock<Promise<void>, [string, { recursive?: boolean }?]>,
|
||||
writeFile: Mock<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>>,
|
||||
mkdir?: Mock<(...args: [string, { recursive?: boolean }?]) => Promise<void>>,
|
||||
): SandboxWorkspace {
|
||||
return {
|
||||
filesystem: {
|
||||
provider: 'local',
|
||||
basePath: '/sandbox',
|
||||
writeFile,
|
||||
mkdir: mkdir ?? jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {}),
|
||||
mkdir:
|
||||
mkdir ??
|
||||
vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise<void>>(async () => {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadSetupSandboxWorkspaceWithFsMocks(
|
||||
async function loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox: RunInSandboxMock,
|
||||
readFileViaSandbox: ReadFileViaSandboxMock,
|
||||
): SetupSandboxWorkspace {
|
||||
jest.resetModules();
|
||||
jest.doMock('../sandbox-fs', () => ({
|
||||
): Promise<SetupSandboxWorkspace> {
|
||||
vi.resetModules();
|
||||
vi.doMock('../sandbox-fs', () => ({
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
writeFileViaSandbox: async (workspace: SandboxWorkspace, path: string) => {
|
||||
|
|
@ -72,62 +76,54 @@ function loadSetupSandboxWorkspaceWithFsMocks(
|
|||
escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"),
|
||||
}));
|
||||
|
||||
let sandboxSetup: { setupSandboxWorkspace: SetupSandboxWorkspace } | undefined;
|
||||
jest.isolateModules(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
sandboxSetup = require('../sandbox-setup') as {
|
||||
setupSandboxWorkspace: SetupSandboxWorkspace;
|
||||
};
|
||||
});
|
||||
const sandboxSetup = (await import('../sandbox-setup')) as {
|
||||
setupSandboxWorkspace: SetupSandboxWorkspace;
|
||||
};
|
||||
|
||||
if (!sandboxSetup) throw new Error('Failed to load sandbox setup module');
|
||||
return sandboxSetup.setupSandboxWorkspace;
|
||||
}
|
||||
|
||||
function loadLinkWorkspaceSdkWithMocks(
|
||||
packWorkspaceSdk: jest.Mock,
|
||||
async function loadLinkWorkspaceSdkWithMocks(
|
||||
packWorkspaceSdk: Mock,
|
||||
runInSandbox: RunInSandboxMock,
|
||||
): LinkWorkspaceSdkIfEnabled {
|
||||
jest.resetModules();
|
||||
jest.doMock('../pack-workspace-sdk', () => ({
|
||||
): Promise<LinkWorkspaceSdkIfEnabled> {
|
||||
vi.resetModules();
|
||||
vi.doMock('../pack-workspace-sdk', () => ({
|
||||
isLinkWorkspaceSdkEnabled: () => true,
|
||||
packWorkspaceSdk,
|
||||
}));
|
||||
jest.doMock('../sandbox-fs', () => ({
|
||||
vi.doMock('../sandbox-fs', () => ({
|
||||
runInSandbox,
|
||||
readFileViaSandbox: jest.fn(),
|
||||
writeFileViaSandbox: jest.fn(),
|
||||
readFileViaSandbox: vi.fn(),
|
||||
writeFileViaSandbox: vi.fn(),
|
||||
escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"),
|
||||
}));
|
||||
|
||||
let sandboxSetup: { linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled } | undefined;
|
||||
jest.isolateModules(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
sandboxSetup = require('../sandbox-setup') as {
|
||||
linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled;
|
||||
};
|
||||
});
|
||||
const sandboxSetup = (await import('../sandbox-setup')) as {
|
||||
linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled;
|
||||
};
|
||||
|
||||
if (!sandboxSetup) throw new Error('Failed to load sandbox setup module');
|
||||
return sandboxSetup.linkWorkspaceSdkIfEnabled;
|
||||
}
|
||||
|
||||
function loadSandboxPackageJson(linkSdk: boolean): {
|
||||
async function loadSandboxPackageJson(linkSdk: boolean): Promise<{
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
} {
|
||||
jest.resetModules();
|
||||
}> {
|
||||
// Ensure no leftover sandbox-fs mock from a sibling describe affects this fresh
|
||||
// import, then re-import sandbox-setup so its env-dependent PACKAGE_JSON constant
|
||||
// is re-evaluated. Using `await import` (rather than `vi.importActual`) keeps the
|
||||
// module-cache interaction consistent with the doMock-based loaders above.
|
||||
vi.doUnmock('../sandbox-fs');
|
||||
vi.resetModules();
|
||||
if (linkSdk) {
|
||||
process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK = '1';
|
||||
} else {
|
||||
delete process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
|
||||
}
|
||||
|
||||
let packageJson = '';
|
||||
jest.isolateModules(() => {
|
||||
const sandboxSetup = jest.requireActual<{ PACKAGE_JSON: string }>('../sandbox-setup');
|
||||
packageJson = sandboxSetup.PACKAGE_JSON;
|
||||
});
|
||||
const sandboxSetup = await import('../sandbox-setup');
|
||||
const packageJson = sandboxSetup.PACKAGE_JSON;
|
||||
|
||||
return jsonParse<{
|
||||
dependencies: Record<string, string>;
|
||||
|
|
@ -139,7 +135,7 @@ describe('PACKAGE_JSON', () => {
|
|||
const originalLinkSdk = process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
vi.resetModules();
|
||||
if (originalLinkSdk === undefined) {
|
||||
delete process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
|
||||
} else {
|
||||
|
|
@ -147,15 +143,15 @@ describe('PACKAGE_JSON', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should include a registry SDK dependency when workspace SDK linking is disabled', () => {
|
||||
const packageJson = loadSandboxPackageJson(false);
|
||||
it('should include a registry SDK dependency when workspace SDK linking is disabled', async () => {
|
||||
const packageJson = await loadSandboxPackageJson(false);
|
||||
|
||||
expect(packageJson.dependencies['@n8n/workflow-sdk']).toBeDefined();
|
||||
expect(packageJson.dependencies.tsx).toBeDefined();
|
||||
});
|
||||
|
||||
it('should omit the registry SDK dependency when workspace SDK linking is enabled', () => {
|
||||
const packageJson = loadSandboxPackageJson(true);
|
||||
it('should omit the registry SDK dependency when workspace SDK linking is enabled', async () => {
|
||||
const packageJson = await loadSandboxPackageJson(true);
|
||||
|
||||
expect(packageJson.dependencies).not.toHaveProperty('@n8n/workflow-sdk');
|
||||
expect(packageJson.dependencies.tsx).toBeDefined();
|
||||
|
|
@ -164,28 +160,28 @@ describe('PACKAGE_JSON', () => {
|
|||
|
||||
describe('setupSandboxWorkspace', () => {
|
||||
afterEach(() => {
|
||||
jest.dontMock('../sandbox-fs');
|
||||
jest.resetModules();
|
||||
vi.doUnmock('../sandbox-fs');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('writes the initialized marker only after workspace files and npm install succeed', async () => {
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
|
||||
Promise<string | null>,
|
||||
[SandboxWorkspace, string]
|
||||
>();
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
|
||||
async () => {},
|
||||
);
|
||||
const writeFile = vi.fn<
|
||||
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
|
||||
>(async () => {});
|
||||
|
||||
await setupSandboxWorkspace(createLocalWorkspace(writeFile), createSetupContext());
|
||||
|
||||
|
|
@ -199,24 +195,26 @@ describe('setupSandboxWorkspace', () => {
|
|||
});
|
||||
|
||||
it('always creates workflows/, src/, and chunks/ even when no workflows exist', async () => {
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
|
||||
Promise<string | null>,
|
||||
[SandboxWorkspace, string]
|
||||
>();
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
|
||||
const writeFile = vi.fn<
|
||||
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
|
||||
>(async () => {});
|
||||
const mkdir = vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise<void>>(
|
||||
async () => {},
|
||||
);
|
||||
const mkdir = jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {});
|
||||
|
||||
// Setup context defaults to an empty workflow list, mirroring a fresh DB.
|
||||
await setupSandboxWorkspace(createLocalWorkspace(writeFile, mkdir), createSetupContext());
|
||||
|
|
@ -231,23 +229,23 @@ describe('setupSandboxWorkspace', () => {
|
|||
// Local provider is for SDK dev iteration; the agent operates fine without
|
||||
// the curated reference set, so setupSandboxWorkspace must not pay the
|
||||
// per-file/archive write cost here.
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
|
||||
Promise<string | null>,
|
||||
[SandboxWorkspace, string]
|
||||
>();
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
|
||||
async () => {},
|
||||
);
|
||||
const writeFile = vi.fn<
|
||||
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
|
||||
>(async () => {});
|
||||
|
||||
const bundle: BuilderTemplatesBundle = {
|
||||
archive: Buffer.from('opaque-archive-bytes'),
|
||||
|
|
@ -264,27 +262,27 @@ describe('setupSandboxWorkspace', () => {
|
|||
});
|
||||
|
||||
it('rejects setup file paths that escape the workspace root', async () => {
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
|
||||
Promise<string | null>,
|
||||
[SandboxWorkspace, string]
|
||||
>();
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
|
||||
async () => {},
|
||||
);
|
||||
const writeFile = vi.fn<
|
||||
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
|
||||
>(async () => {});
|
||||
const context = createSetupContext();
|
||||
const workflowService = context.workflowService as unknown as {
|
||||
list: jest.Mock<Promise<Array<{ id: string }>>, [{ limit: number }]>;
|
||||
get: jest.Mock<Promise<Record<string, unknown>>, [string]>;
|
||||
list: Mock<(...args: [{ limit: number }]) => Promise<Array<{ id: string }>>>;
|
||||
get: Mock<(...args: [string]) => Promise<Record<string, unknown>>>;
|
||||
};
|
||||
workflowService.list.mockResolvedValue([{ id: '../escape' }]);
|
||||
workflowService.get.mockResolvedValue({ id: '../escape' });
|
||||
|
|
@ -295,23 +293,23 @@ describe('setupSandboxWorkspace', () => {
|
|||
});
|
||||
|
||||
it('does not write the initialized marker when npm install fails', async () => {
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 1, stdout: '', stderr: 'install failed' });
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
|
||||
Promise<string | null>,
|
||||
[SandboxWorkspace, string]
|
||||
>();
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
|
||||
async () => {},
|
||||
);
|
||||
const writeFile = vi.fn<
|
||||
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
|
||||
>(async () => {});
|
||||
|
||||
await expect(
|
||||
setupSandboxWorkspace(createLocalWorkspace(writeFile), createSetupContext()),
|
||||
|
|
@ -325,22 +323,22 @@ describe('setupSandboxWorkspace', () => {
|
|||
});
|
||||
|
||||
it('uses command fallback when a filesystem marker write fails', async () => {
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
|
||||
Promise<string | null>,
|
||||
[SandboxWorkspace, string]
|
||||
>();
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const writeFile = jest
|
||||
.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>()
|
||||
const writeFile = vi
|
||||
.fn<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>>()
|
||||
.mockImplementation(async (path) => {
|
||||
await Promise.resolve();
|
||||
if (path === '/sandbox/.sandbox-initialized') {
|
||||
|
|
@ -358,27 +356,27 @@ describe('setupSandboxWorkspace', () => {
|
|||
});
|
||||
|
||||
it('includes the failing setup step when marker fallback fails', async () => {
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockImplementation(async (_workspace, command) => {
|
||||
await Promise.resolve();
|
||||
return command.includes('.sandbox-initialized')
|
||||
? { exitCode: 1, stdout: '', stderr: 'fallback failed' }
|
||||
: { exitCode: 0, stdout: '', stderr: '' };
|
||||
});
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn<
|
||||
Promise<string | null>,
|
||||
[SandboxWorkspace, string]
|
||||
>();
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
const setupSandboxWorkspace = await loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const writeFile = jest
|
||||
.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>()
|
||||
const writeFile = vi
|
||||
.fn<(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>>()
|
||||
.mockImplementation(async (path) => {
|
||||
await Promise.resolve();
|
||||
if (path === '/sandbox/.sandbox-initialized') {
|
||||
|
|
@ -406,19 +404,24 @@ describe('setupSandboxWorkspace', () => {
|
|||
const originalLinkSdk = process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK;
|
||||
process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK = '1';
|
||||
const tarball = Buffer.from('sdk');
|
||||
const packWorkspaceSdk = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
const packWorkspaceSdk = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
filename: 'workflow-sdk.tgz',
|
||||
tarball,
|
||||
version: '1.0.0',
|
||||
sdkPath: '/host/sdk',
|
||||
});
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
|
||||
const linkWorkspaceSdkIfEnabled = loadLinkWorkspaceSdkWithMocks(packWorkspaceSdk, runInSandbox);
|
||||
const writeFile = jest.fn<Promise<void>, [string, Buffer, { recursive?: boolean }?]>(
|
||||
const linkWorkspaceSdkIfEnabled = await loadLinkWorkspaceSdkWithMocks(
|
||||
packWorkspaceSdk,
|
||||
runInSandbox,
|
||||
);
|
||||
const writeFile = vi.fn<(...args: [string, Buffer, { recursive?: boolean }?]) => Promise<void>>(
|
||||
async () => {},
|
||||
);
|
||||
const workspace = {
|
||||
|
|
@ -451,8 +454,8 @@ describe('setupSandboxWorkspace', () => {
|
|||
describe('getWorkspaceRoot', () => {
|
||||
it('uses the resolved filesystem base path for lazy local workspaces', async () => {
|
||||
let initialized = false;
|
||||
const executeCommand = jest.fn();
|
||||
const init = jest.fn<Promise<void>, []>(async () => {
|
||||
const executeCommand = vi.fn();
|
||||
const init = vi.fn<(...args: []) => Promise<void>>(async () => {
|
||||
await Promise.resolve();
|
||||
initialized = true;
|
||||
});
|
||||
|
|
@ -463,8 +466,8 @@ describe('getWorkspaceRoot', () => {
|
|||
return initialized ? '/sandbox' : undefined;
|
||||
},
|
||||
init,
|
||||
writeFile: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
},
|
||||
sandbox: {
|
||||
executeCommand,
|
||||
|
|
@ -480,54 +483,55 @@ describe('getWorkspaceRoot', () => {
|
|||
|
||||
describe('writeCuratedExamples', () => {
|
||||
afterEach(() => {
|
||||
jest.dontMock('../sandbox-fs');
|
||||
jest.resetModules();
|
||||
vi.doUnmock('../sandbox-fs');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
type WriteCuratedExamples = (
|
||||
workspace: SandboxWorkspace,
|
||||
bundle: BuilderTemplatesBundle | null,
|
||||
logger?: { debug?: jest.Mock; warn?: jest.Mock },
|
||||
logger?: { debug?: Mock; warn?: Mock },
|
||||
) => Promise<void>;
|
||||
|
||||
type FsMocks = {
|
||||
runInSandbox: RunInSandboxMock;
|
||||
writeFileViaSandbox: jest.Mock<Promise<void>, [SandboxWorkspace, string, string | Buffer]>;
|
||||
writeFileViaSandbox: Mock<
|
||||
(...args: [SandboxWorkspace, string, string | Buffer]) => Promise<void>
|
||||
>;
|
||||
};
|
||||
|
||||
function loadWriteCuratedExamples(): { fn: WriteCuratedExamples; fs: FsMocks } {
|
||||
const runInSandbox: RunInSandboxMock = jest.fn<
|
||||
Promise<{ exitCode: number; stdout: string; stderr: string }>,
|
||||
[SandboxWorkspace, string, string?]
|
||||
>();
|
||||
async function loadWriteCuratedExamples(): Promise<{ fn: WriteCuratedExamples; fs: FsMocks }> {
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
|
||||
const writeFileViaSandbox = jest.fn<Promise<void>, [SandboxWorkspace, string, string | Buffer]>(
|
||||
async () => {},
|
||||
);
|
||||
jest.resetModules();
|
||||
jest.doMock('../sandbox-fs', () => ({
|
||||
const writeFileViaSandbox = vi.fn<
|
||||
(...args: [SandboxWorkspace, string, string | Buffer]) => Promise<void>
|
||||
>(async () => {});
|
||||
vi.resetModules();
|
||||
vi.doMock('../sandbox-fs', () => ({
|
||||
runInSandbox,
|
||||
readFileViaSandbox: jest.fn().mockResolvedValue(null),
|
||||
readFileViaSandbox: vi.fn().mockResolvedValue(null),
|
||||
writeFileViaSandbox,
|
||||
escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"),
|
||||
}));
|
||||
|
||||
let loaded: { writeCuratedExamples: WriteCuratedExamples } | undefined;
|
||||
jest.isolateModules(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
loaded = require('../sandbox-setup') as {
|
||||
writeCuratedExamples: WriteCuratedExamples;
|
||||
};
|
||||
});
|
||||
if (!loaded) throw new Error('Failed to load sandbox-setup');
|
||||
const loaded = (await import('../sandbox-setup')) as {
|
||||
writeCuratedExamples: WriteCuratedExamples;
|
||||
};
|
||||
return { fn: loaded.writeCuratedExamples, fs: { runInSandbox, writeFileViaSandbox } };
|
||||
}
|
||||
|
||||
function makeDaytonaWorkspace() {
|
||||
const filesystem = {
|
||||
provider: 'daytona' as const,
|
||||
writeFile: jest.fn<Promise<void>, [string, Buffer, { recursive?: boolean }?]>(async () => {}),
|
||||
mkdir: jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {}),
|
||||
writeFile: vi.fn<(...args: [string, Buffer, { recursive?: boolean }?]) => Promise<void>>(
|
||||
async () => {},
|
||||
),
|
||||
mkdir: vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise<void>>(async () => {}),
|
||||
};
|
||||
const workspace = { filesystem } as unknown as SandboxWorkspace;
|
||||
return { workspace, filesystem };
|
||||
|
|
@ -601,7 +605,7 @@ describe('writeCuratedExamples', () => {
|
|||
]);
|
||||
|
||||
it('writes the archive and runs tar on a non-local provider', async () => {
|
||||
const { fn, fs } = loadWriteCuratedExamples();
|
||||
const { fn, fs } = await loadWriteCuratedExamples();
|
||||
const { workspace, filesystem } = makeDaytonaWorkspace();
|
||||
|
||||
await fn(workspace, { archive: ARCHIVE, version: '"v1"' });
|
||||
|
|
@ -626,7 +630,7 @@ describe('writeCuratedExamples', () => {
|
|||
});
|
||||
|
||||
it('falls back to shell writes when the workspace has no filesystem', async () => {
|
||||
const { fn, fs } = loadWriteCuratedExamples();
|
||||
const { fn, fs } = await loadWriteCuratedExamples();
|
||||
const workspace = makeShellOnlyWorkspace();
|
||||
|
||||
await fn(workspace, { archive: ARCHIVE, version: '"v1"' });
|
||||
|
|
@ -646,14 +650,14 @@ describe('writeCuratedExamples', () => {
|
|||
});
|
||||
|
||||
it('warns and continues when tar exits non-zero', async () => {
|
||||
const { fn, fs } = loadWriteCuratedExamples();
|
||||
const { fn, fs } = await loadWriteCuratedExamples();
|
||||
fs.runInSandbox.mockImplementation(async (_, cmd) => {
|
||||
const stderr = cmd.includes('tar -xzf') ? 'tar: bad archive' : '';
|
||||
const exitCode = cmd.includes('tar -xzf') ? 1 : 0;
|
||||
return await Promise.resolve({ exitCode, stdout: '', stderr });
|
||||
});
|
||||
const { workspace } = makeDaytonaWorkspace();
|
||||
const logger = { debug: jest.fn(), warn: jest.fn() };
|
||||
const logger = { debug: vi.fn(), warn: vi.fn() };
|
||||
|
||||
// Must not throw.
|
||||
await fn(workspace, { archive: ARCHIVE, version: '"v1"' }, logger);
|
||||
|
|
@ -672,9 +676,9 @@ describe('writeCuratedExamples', () => {
|
|||
['hardlink entry', makeTarGz([{ name: 'link.ts', typeFlag: '1', linkName: 'target.ts' }])],
|
||||
['malformed gzip', Buffer.from('not-a-gzip-archive')],
|
||||
])('rejects an archive with %s before writing it', async (_label, archive) => {
|
||||
const { fn, fs } = loadWriteCuratedExamples();
|
||||
const { fn, fs } = await loadWriteCuratedExamples();
|
||||
const { workspace, filesystem } = makeDaytonaWorkspace();
|
||||
const logger = { debug: jest.fn(), warn: jest.fn() };
|
||||
const logger = { debug: vi.fn(), warn: vi.fn() };
|
||||
|
||||
await fn(workspace, { archive, version: '"v1"' }, logger);
|
||||
|
||||
|
|
@ -688,7 +692,7 @@ describe('writeCuratedExamples', () => {
|
|||
});
|
||||
|
||||
it('no-ops when bundle.archive is null', async () => {
|
||||
const { fn, fs } = loadWriteCuratedExamples();
|
||||
const { fn, fs } = await loadWriteCuratedExamples();
|
||||
const { workspace, filesystem } = makeDaytonaWorkspace();
|
||||
|
||||
await fn(workspace, { archive: null, version: null });
|
||||
|
|
@ -698,7 +702,7 @@ describe('writeCuratedExamples', () => {
|
|||
});
|
||||
|
||||
it('no-ops when bundle is null', async () => {
|
||||
const { fn, fs } = loadWriteCuratedExamples();
|
||||
const { fn, fs } = await loadWriteCuratedExamples();
|
||||
const { workspace, filesystem } = makeDaytonaWorkspace();
|
||||
|
||||
await fn(workspace, null);
|
||||
|
|
@ -708,16 +712,18 @@ describe('writeCuratedExamples', () => {
|
|||
});
|
||||
|
||||
it('skips the local provider even with a non-empty bundle', async () => {
|
||||
const { fn, fs } = loadWriteCuratedExamples();
|
||||
const writeFile = jest.fn<Promise<void>, [string, string | Buffer, { recursive?: boolean }?]>(
|
||||
async () => {},
|
||||
);
|
||||
const { fn, fs } = await loadWriteCuratedExamples();
|
||||
const writeFile = vi.fn<
|
||||
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
|
||||
>(async () => {});
|
||||
const workspace = {
|
||||
filesystem: {
|
||||
provider: 'local',
|
||||
basePath: '/sandbox',
|
||||
writeFile,
|
||||
mkdir: jest.fn<Promise<void>, [string, { recursive?: boolean }?]>(async () => {}),
|
||||
mkdir: vi.fn<(...args: [string, { recursive?: boolean }?]) => Promise<void>>(
|
||||
async () => {},
|
||||
),
|
||||
},
|
||||
} as unknown as SandboxWorkspace;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
type WorkspaceFilesystem,
|
||||
type WorkspaceSandbox,
|
||||
} from '@n8n/agents';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { createScopedWorkspace } from '../scoped-workspace';
|
||||
|
||||
|
|
@ -13,31 +14,31 @@ function createFilesystem(overrides: Partial<WorkspaceFilesystem> = {}): Workspa
|
|||
name: 'filesystem',
|
||||
provider: 'test',
|
||||
status: 'ready',
|
||||
readFile: jest.fn(async () => await Promise.resolve('content')),
|
||||
writeFile: jest.fn(async () => {
|
||||
readFile: vi.fn(async () => await Promise.resolve('content')),
|
||||
writeFile: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
}),
|
||||
appendFile: jest.fn(async () => {
|
||||
appendFile: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
}),
|
||||
deleteFile: jest.fn(async () => {
|
||||
deleteFile: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
}),
|
||||
copyFile: jest.fn(async () => {
|
||||
copyFile: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
}),
|
||||
moveFile: jest.fn(async () => {
|
||||
moveFile: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
}),
|
||||
mkdir: jest.fn(async () => {
|
||||
mkdir: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
}),
|
||||
rmdir: jest.fn(async () => {
|
||||
rmdir: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
}),
|
||||
readdir: jest.fn(async () => await Promise.resolve([])),
|
||||
exists: jest.fn(async () => await Promise.resolve(true)),
|
||||
stat: jest.fn(
|
||||
readdir: vi.fn(async () => await Promise.resolve([])),
|
||||
exists: vi.fn(async () => await Promise.resolve(true)),
|
||||
stat: vi.fn(
|
||||
async () =>
|
||||
await Promise.resolve({
|
||||
name: 'workflow.ts',
|
||||
|
|
@ -52,7 +53,7 @@ function createFilesystem(overrides: Partial<WorkspaceFilesystem> = {}): Workspa
|
|||
};
|
||||
}
|
||||
|
||||
function createSandbox(executeCommand: jest.Mock | null = jest.fn()): WorkspaceSandbox {
|
||||
function createSandbox(executeCommand: Mock | null = vi.fn()): WorkspaceSandbox {
|
||||
const result: CommandResult = {
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
|
|
@ -103,7 +104,7 @@ describe('createScopedWorkspace', () => {
|
|||
});
|
||||
|
||||
it('runs commands from the builder root and merges scoped environment variables', async () => {
|
||||
const executeCommand = jest.fn();
|
||||
const executeCommand = vi.fn();
|
||||
const sandbox = createSandbox(executeCommand);
|
||||
const workspace = createScopedWorkspace(new Workspace({ sandbox }), root, {
|
||||
N8N_WORKSPACE_DIR: root,
|
||||
|
|
@ -121,7 +122,7 @@ describe('createScopedWorkspace', () => {
|
|||
});
|
||||
|
||||
it('rejects command working directories outside the builder root', async () => {
|
||||
const executeCommand = jest.fn();
|
||||
const executeCommand = vi.fn();
|
||||
const sandbox = createSandbox(executeCommand);
|
||||
const workspace = createScopedWorkspace(new Workspace({ sandbox }), root);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// Mock the Daytona SDK before importing — its source has require() paths that
|
||||
// jest can't resolve in this monorepo, and we don't need the real types here.
|
||||
jest.mock('@daytonaio/sdk', () => {
|
||||
/* eslint-disable import-x/order */
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
// The Daytona SDK is consumed in source via `loadDaytona()` (which `require()`s
|
||||
// @daytonaio/sdk — a path the test runner can't resolve in this monorepo), so we
|
||||
// mock the first-party `lazy-daytona` module. The mock classes live in vi.hoisted
|
||||
// so they are shared between the mock factory and the test (`instanceof` checks in
|
||||
// source must see the same DaytonaError the test constructs).
|
||||
const { DaytonaError, DaytonaNotFoundError, Image } = vi.hoisted(() => {
|
||||
class DaytonaError extends Error {
|
||||
statusCode?: number;
|
||||
constructor(message: string, statusCode?: number) {
|
||||
|
|
@ -38,7 +44,10 @@ jest.mock('@daytonaio/sdk', () => {
|
|||
return { DaytonaError, DaytonaNotFoundError, Image };
|
||||
});
|
||||
|
||||
import { DaytonaError } from '@daytonaio/sdk';
|
||||
vi.mock('../lazy-daytona', () => ({
|
||||
loadDaytona: () => ({ DaytonaError, DaytonaNotFoundError, Image }),
|
||||
}));
|
||||
|
||||
import {
|
||||
RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
|
||||
type RuntimeSkillLinkedFiles,
|
||||
|
|
@ -67,8 +76,8 @@ interface CreateSnapshotParams {
|
|||
}
|
||||
|
||||
interface FakeSnapshotApi {
|
||||
get: jest.Mock<Promise<{ name: string }>, [string]>;
|
||||
create: jest.Mock<Promise<{ name: string }>, [CreateSnapshotParams, unknown?]>;
|
||||
get: Mock<(...args: [string]) => Promise<{ name: string }>>;
|
||||
create: Mock<(...args: [CreateSnapshotParams, unknown?]) => Promise<{ name: string }>>;
|
||||
}
|
||||
|
||||
interface FakeDaytona {
|
||||
|
|
@ -114,8 +123,8 @@ function createRuntimeSkillSource(skillsHash: string): RuntimeSkillSource {
|
|||
function makeFakeDaytona(): FakeDaytona {
|
||||
return {
|
||||
snapshot: {
|
||||
get: jest.fn<Promise<{ name: string }>, [string]>(),
|
||||
create: jest.fn<Promise<{ name: string }>, [CreateSnapshotParams, unknown?]>(),
|
||||
get: vi.fn<(...args: [string]) => Promise<{ name: string }>>(),
|
||||
create: vi.fn<(...args: [CreateSnapshotParams, unknown?]) => Promise<{ name: string }>>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -288,7 +297,7 @@ describe('SnapshotManager.createSnapshot', () => {
|
|||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
|
||||
const onLogs = jest.fn();
|
||||
const onLogs = vi.fn();
|
||||
|
||||
await manager.createSnapshot(daytona as never, { timeout: 1800, onLogs });
|
||||
|
||||
|
|
@ -386,7 +395,7 @@ describe('SnapshotManager.ensureSnapshot', () => {
|
|||
});
|
||||
|
||||
it('reports transient failures via the error reporter', async () => {
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const errorReporter = { error: vi.fn() };
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
|
||||
const daytona = makeFakeDaytona();
|
||||
const error = new DaytonaError('upstream 500', 500);
|
||||
|
|
@ -403,7 +412,7 @@ describe('SnapshotManager.ensureSnapshot', () => {
|
|||
});
|
||||
|
||||
it('does not report when create succeeds', async () => {
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const errorReporter = { error: vi.fn() };
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
|
||||
|
|
@ -414,7 +423,7 @@ describe('SnapshotManager.ensureSnapshot', () => {
|
|||
});
|
||||
|
||||
it('does not report 409/already-exists as an error', async () => {
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const errorReporter = { error: vi.fn() };
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockRejectedValue(new DaytonaError('already exists', 409));
|
||||
|
|
|
|||
|
|
@ -8,18 +8,18 @@ function createWorkspaceTarget(files: Map<string, string>): {
|
|||
const writes = new Map<string, string>();
|
||||
const target: WorkspaceFileTarget = {
|
||||
filesystem: {
|
||||
readFile: jest.fn(async (path: string) => {
|
||||
readFile: vi.fn(async (path: string) => {
|
||||
const content = files.get(path);
|
||||
if (content === undefined) throw new Error('missing');
|
||||
return await Promise.resolve(content);
|
||||
}),
|
||||
writeFile: jest.fn(async (path: string, content: string | Buffer) => {
|
||||
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
|
||||
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
|
||||
await Promise.resolve();
|
||||
}),
|
||||
},
|
||||
sandbox: {
|
||||
executeCommand: jest.fn(async (command: string) => {
|
||||
executeCommand: vi.fn(async (command: string) => {
|
||||
const readMatch = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
|
||||
if (readMatch) {
|
||||
const content = files.get(readMatch[1]);
|
||||
|
|
@ -49,7 +49,7 @@ describe('workspace-files', () => {
|
|||
it('reads via sandbox commands when no filesystem reader is available', async () => {
|
||||
const { target } = createWorkspaceTarget(new Map([['/tmp/manifest.json', '{"ok":true}']]));
|
||||
target.filesystem = {
|
||||
writeFile: jest.fn(async () => {}),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await expect(readWorkspaceFile(target, '/tmp/manifest.json')).resolves.toBe('{"ok":true}');
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
|
||||
"types": ["node", "jest"]
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
64
packages/@n8n/instance-ai/vite.config.ts
Normal file
64
packages/@n8n/instance-ai/vite.config.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import path from 'node:path';
|
||||
import { mergeConfig, type Plugin } from 'vite';
|
||||
import { createVitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
/**
|
||||
* `src/tools/index.ts` lazy-loads each tool module with `require('./x.tool')`.
|
||||
* Under the production `tsc` build that resolves to the compiled `dist/*.js`, but
|
||||
* Vitest's module runner hands the (ESM) source a Node `createRequire`, which
|
||||
* cannot resolve relative `.ts` files — and `vi.mock` only intercepts ESM imports,
|
||||
* not these `require()`s. This transform (test-time only; the source on disk is
|
||||
* untouched) rewrites those `require('spec')` calls into eager static
|
||||
* `import * as __lazymod_N` bindings so Vite resolves them and `vi.mock` applies.
|
||||
*/
|
||||
function rewriteLazyRequireForTests(): Plugin {
|
||||
return {
|
||||
name: 'instance-ai-rewrite-lazy-require',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (!id.replace(/\\/g, '/').endsWith('/src/tools/index.ts')) return null;
|
||||
const requireRe = /require\((['"])([^'"]+)\1\)/g;
|
||||
const specs: string[] = [];
|
||||
for (const match of code.matchAll(requireRe)) {
|
||||
if (!specs.includes(match[2])) specs.push(match[2]);
|
||||
}
|
||||
if (specs.length === 0) return null;
|
||||
const imports = specs
|
||||
.map((spec, index) => `import * as __lazymod_${index} from '${spec}';`)
|
||||
.join('\n');
|
||||
const rewritten = code.replace(
|
||||
requireRe,
|
||||
(_full, _quote, spec: string) => `__lazymod_${specs.indexOf(spec)}`,
|
||||
);
|
||||
return { code: `${imports}\n${rewritten}`, map: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default mergeConfig(
|
||||
createVitestConfig({
|
||||
// Parity with the previous root Jest config, which set `restoreMocks: true`.
|
||||
// Most test files rely on mocks being restored automatically between tests.
|
||||
restoreMocks: true,
|
||||
}),
|
||||
{
|
||||
plugins: [rewriteLazyRequireForTests()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: '@', replacement: path.resolve(__dirname, './src') },
|
||||
// zod has dual ESM/CJS exports (two separate `ZodType` class identities).
|
||||
// Workspace deps CJS-require zod while test files ESM-import it, so
|
||||
// `instanceof` checks (e.g. in sanitize-mcp-schemas) fail across the two
|
||||
// module instances. Pin the top-level `zod` import to the CJS file so all
|
||||
// code paths share one instance. Subpaths like `zod/v4` resolve normally.
|
||||
{
|
||||
find: /^zod$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
'../../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/cjs/index.js',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user