first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,778 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as helperModule from '@utils/helpers';
|
||||
import * as outputParserModule from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import { ChainLlm } from '../ChainLlm.node';
|
||||
import * as executeChainModule from '../methods/chainExecutor';
|
||||
import * as responseFormatterModule from '../methods/responseFormatter';
|
||||
|
||||
jest.mock('@utils/helpers', () => ({
|
||||
getPromptInputByType: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/output_parsers/N8nOutputParser', () => ({
|
||||
getOptionalOutputParser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/chainExecutor', () => ({
|
||||
executeChain: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/responseFormatter', () => ({
|
||||
formatResponse: jest.fn().mockImplementation((response) => {
|
||||
if (typeof response === 'string') {
|
||||
return { text: response.trim() };
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ChainLlm Node', () => {
|
||||
let node: ChainLlm;
|
||||
let mockExecuteFunction: jest.Mocked<IExecuteFunctions>;
|
||||
let needsFallback: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new ChainLlm();
|
||||
mockExecuteFunction = mock<IExecuteFunctions>();
|
||||
|
||||
mockExecuteFunction.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
needsFallback = false;
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'messages.messageValues') return [];
|
||||
if (param === 'needsFallback') return needsFallback;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue(fakeLLM);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('description', () => {
|
||||
it('should have the expected properties', () => {
|
||||
expect(node.description).toBeDefined();
|
||||
expect(node.description.name).toBe('chainLlm');
|
||||
expect(node.description.displayName).toBe('Basic LLM Chain');
|
||||
expect(node.description.version).toContain(1.5);
|
||||
expect(node.description.properties).toBeDefined();
|
||||
expect(node.description.inputs).toBeDefined();
|
||||
expect(node.description.outputs).toEqual([NodeConnectionTypes.Main]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute the chain with the correct parameters', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt',
|
||||
fallbackLlm: null,
|
||||
llm: expect.any(FakeChatModel),
|
||||
outputParser: undefined,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(mockExecuteFunction.logger.debug).toHaveBeenCalledWith('Executing Basic LLM Chain');
|
||||
|
||||
expect(result).toEqual([[{ json: expect.any(Object) }]]);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(2);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should use the prompt parameter directly for older versions', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.3,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'prompt') return 'Old version prompt';
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Old version prompt',
|
||||
fallbackLlm: null,
|
||||
llm: expect.any(Object),
|
||||
outputParser: undefined,
|
||||
messages: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if prompt is empty', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
mockExecuteFunction.getNode.mockReturnValue({ name: 'Test Node' } as INode);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunction)).rejects.toThrow(/prompt.*empty/);
|
||||
});
|
||||
|
||||
it('should continue on failure when configured', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
const error = new Error('Test error');
|
||||
(executeChainModule.executeChain as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([[{ json: { error: 'Test error' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('should handle multiple response items from executeChain', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([
|
||||
'Response 1',
|
||||
'Response 2',
|
||||
]);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('batching (version 1.7+)', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.7,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
});
|
||||
|
||||
it('should process items in batches with default settings', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(3);
|
||||
expect(result[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should process items in smaller batches', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3')
|
||||
.mockReturnValueOnce('Test prompt 4');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3'])
|
||||
.mockResolvedValueOnce(['Response 4']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(4);
|
||||
expect(result[0]).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should handle errors in batches with continueOnFail', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockRejectedValueOnce(new Error('Test error'));
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][1].json).toEqual({ error: 'Test error' });
|
||||
});
|
||||
|
||||
it('should handle OpenAI rate limit errors in batches', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
const openAiError = new NodeApiError(mockExecuteFunction.getNode(), {
|
||||
message: 'Rate limit exceeded',
|
||||
cause: { error: { code: 'rate_limit_exceeded' } },
|
||||
});
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockRejectedValueOnce(openAiError);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][1].json).toEqual({ error: expect.stringContaining('Rate limit') });
|
||||
});
|
||||
});
|
||||
|
||||
it('should unwrap object responses when node version is 1.6 or higher', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const structuredResponse = {
|
||||
person: { name: 'John', age: 30 },
|
||||
items: ['item1', 'item2'],
|
||||
active: true,
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, true);
|
||||
});
|
||||
|
||||
it('should unwrap object responses when output parser is provided regardless of version', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
);
|
||||
|
||||
const structuredResponse = {
|
||||
result: 'success',
|
||||
data: { key: 'value' },
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, true);
|
||||
});
|
||||
|
||||
it('should wrap object responses as text when node version is lower than 1.6 and no output parser', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const structuredResponse = {
|
||||
person: { name: 'John', age: 30 },
|
||||
items: ['item1', 'item2'],
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, false);
|
||||
});
|
||||
|
||||
it('should handle a mix of different response types with the correct wrapping', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const mixedResponses = ['Text response', { structured: 'object' }, ['array', 'response']];
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(mixedResponses);
|
||||
|
||||
(responseFormatterModule.formatResponse as jest.Mock).mockClear();
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenCalledTimes(3);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Text response',
|
||||
true,
|
||||
);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ structured: 'object' },
|
||||
true,
|
||||
);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['array', 'response'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle LLM responses containing JSON with markdown content', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue(
|
||||
'Generate markdown documentation',
|
||||
);
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const markdownResponse = {
|
||||
title: 'API Documentation',
|
||||
sections: [
|
||||
{
|
||||
name: 'Authentication',
|
||||
content:
|
||||
"# Authentication\n\nUse API keys for all requests:\n\n```javascript\nconst headers = {\n 'Authorization': 'Bearer YOUR_API_KEY'\n};\n```",
|
||||
},
|
||||
{
|
||||
name: 'Endpoints',
|
||||
content:
|
||||
'## Available Endpoints\n\n* GET /users - List all users\n* POST /users - Create a user\n* GET /users/{id} - Get user details',
|
||||
},
|
||||
],
|
||||
examples: {
|
||||
curl: "```bash\ncurl -X GET https://api.example.com/users \\\n -H 'Authorization: Bearer YOUR_API_KEY'\n```",
|
||||
response: '```json\n{\n "users": [],\n "count": 0\n}\n```',
|
||||
},
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([markdownResponse]);
|
||||
|
||||
(responseFormatterModule.formatResponse as jest.Mock).mockImplementation(
|
||||
(response, shouldUnwrap) => {
|
||||
if (shouldUnwrap && typeof response === 'object') {
|
||||
return response;
|
||||
}
|
||||
return { text: JSON.stringify(response) };
|
||||
},
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: markdownResponse,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenCalledWith(markdownResponse, true);
|
||||
});
|
||||
|
||||
it('should use fallback llm if enabled', async () => {
|
||||
needsFallback = true;
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const fakeFallbackLLM = new FakeChatModel({});
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([fakeLLM, fakeFallbackLLM]);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt',
|
||||
fallbackLlm: expect.any(FakeChatModel),
|
||||
llm: expect.any(FakeChatModel),
|
||||
outputParser: undefined,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(mockExecuteFunction.logger.debug).toHaveBeenCalledWith('Executing Basic LLM Chain');
|
||||
|
||||
expect(result).toEqual([[{ json: expect.any(Object) }]]);
|
||||
});
|
||||
|
||||
it('should pass correct itemIndex to getOptionalOutputParser', async () => {
|
||||
// Clear any previous calls to the mock
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockClear();
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3');
|
||||
|
||||
const mockParser1 = mock<outputParserModule.N8nOutputParser>();
|
||||
const mockParser2 = mock<outputParserModule.N8nOutputParser>();
|
||||
const mockParser3 = mock<outputParserModule.N8nOutputParser>();
|
||||
|
||||
// Use the already mocked function instead of creating a spy
|
||||
// First call is for the initial check in execute(), then one per item
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock)
|
||||
.mockResolvedValueOnce(undefined) // Initial call in execute()
|
||||
.mockResolvedValueOnce(mockParser1)
|
||||
.mockResolvedValueOnce(mockParser2)
|
||||
.mockResolvedValueOnce(mockParser3);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3']);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// Verify getOptionalOutputParser was called with correct indices
|
||||
// First call without index, then 3 calls with indices
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenCalledTimes(4);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockExecuteFunction,
|
||||
); // Initial call
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockExecuteFunction,
|
||||
0,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockExecuteFunction,
|
||||
1,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
mockExecuteFunction,
|
||||
2,
|
||||
);
|
||||
|
||||
// Verify executeChain was called with the corresponding parsers
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(1, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt 1',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser1,
|
||||
messages: [],
|
||||
});
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(2, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 1,
|
||||
query: 'Test prompt 2',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser2,
|
||||
messages: [],
|
||||
});
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(3, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 2,
|
||||
query: 'Test prompt 3',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser3,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different output parsers for each item', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
// First item has no parser, second has a parser
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(mock<outputParserModule.N8nOutputParser>());
|
||||
|
||||
const response1 = { text: 'plain response' };
|
||||
const response2 = { structured: 'response' };
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce([response1])
|
||||
.mockResolvedValueOnce([response2]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// First item without parser should not unwrap objects (even in v1.6)
|
||||
// Actually, let me check the logic again... v1.6 unwraps by default
|
||||
// but having an output parser always triggers unwrapping
|
||||
expect(formatResponseSpy).toHaveBeenNthCalledWith(1, response1, true); // v1.6 unwraps
|
||||
expect(formatResponseSpy).toHaveBeenNthCalledWith(2, response2, true); // has parser, unwraps
|
||||
});
|
||||
|
||||
it('should maintain parser consistency across batch processing', async () => {
|
||||
// Clear any previous calls to the mock
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockClear();
|
||||
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.7,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3')
|
||||
.mockReturnValueOnce('Test prompt 4');
|
||||
|
||||
const mockParsers = [
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
undefined,
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
undefined,
|
||||
];
|
||||
|
||||
// Use the already mocked function instead of creating a spy
|
||||
// Account for initial call without index
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockImplementation(
|
||||
async (_ctx, index) => {
|
||||
if (index === undefined) return undefined; // Initial call
|
||||
return mockParsers[index];
|
||||
},
|
||||
);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3'])
|
||||
.mockResolvedValueOnce(['Response 4']);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// Verify each item was processed with correct index
|
||||
// First call without index, then 4 calls with indices
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenCalledTimes(5);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockExecuteFunction,
|
||||
); // Initial call
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockExecuteFunction,
|
||||
0,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockExecuteFunction,
|
||||
1,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
mockExecuteFunction,
|
||||
2,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
mockExecuteFunction,
|
||||
3,
|
||||
);
|
||||
|
||||
// Verify executeChain received correct parsers
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[0],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[1],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[2],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[3],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { JsonOutputParser, StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import { ChatPromptTemplate, PromptTemplate } from '@langchain/core/prompts';
|
||||
import { FakeLLM, FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
import * as tracing from '@utils/tracing';
|
||||
|
||||
import { executeChain, NaiveJsonOutputParser } from '../methods/chainExecutor';
|
||||
import * as chainExecutor from '../methods/chainExecutor';
|
||||
import * as promptUtils from '../methods/promptUtils';
|
||||
|
||||
jest.mock('@utils/tracing', () => ({
|
||||
getTracingConfig: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/promptUtils', () => ({
|
||||
createPromptTemplate: jest.fn(),
|
||||
getAgentStepsParser: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('chainExecutor', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getExecutionCancelSignal = jest.fn().mockReturnValue(undefined);
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.5,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOutputParserForLLM', () => {
|
||||
it('should return NaiveJsonOutputParser for OpenAI-like models with json_object response format', () => {
|
||||
const openAILikeModel = {
|
||||
modelKwargs: {
|
||||
response_format: {
|
||||
type: 'json_object',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(
|
||||
openAILikeModel as unknown as BaseChatModel,
|
||||
);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for Ollama models with json format', () => {
|
||||
const ollamaLikeModel = {
|
||||
format: 'json',
|
||||
};
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(
|
||||
ollamaLikeModel as unknown as BaseChatModel,
|
||||
);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return StringOutputParser for models without JSON format settings', () => {
|
||||
const regularModel = new FakeLLM({});
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(regularModel);
|
||||
expect(parser).toBeInstanceOf(StringOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for Anthropic models in thinking mode', () => {
|
||||
const model = {
|
||||
lc_kwargs: {
|
||||
invocationKwargs: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const parser = chainExecutor.getOutputParserForLLM(model as unknown as BaseChatModel);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for models with metadata output_format set to json', () => {
|
||||
const model = mock<BaseChatModel>({
|
||||
metadata: {
|
||||
output_format: 'json',
|
||||
},
|
||||
});
|
||||
const parser = chainExecutor.getOutputParserForLLM(model);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return StringOutputParser for models with metadata output_format not set to json', () => {
|
||||
const model = mock<BaseChatModel>({
|
||||
metadata: {
|
||||
output_format: 'text',
|
||||
},
|
||||
});
|
||||
const parser = chainExecutor.getOutputParserForLLM(model);
|
||||
expect(parser).toBeInstanceOf(StringOutputParser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NaiveJsonOutputParser', () => {
|
||||
it('should parse valid JSON directly', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonStr = '{"name": "John", "age": 30}';
|
||||
|
||||
const result = await parser.parse(jsonStr);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested JSON objects', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonStr = '{"person": {"name": "John", "age": 30}, "active": true}';
|
||||
|
||||
const result = await parser.parse(jsonStr);
|
||||
|
||||
expect(result).toEqual({
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
},
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use parent class parser for malformed JSON', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const superParseSpy = jest.spyOn(JsonOutputParser.prototype, 'parse').mockResolvedValue({
|
||||
parsed: 'content',
|
||||
});
|
||||
|
||||
const malformedJson = 'Sure, here is your JSON: {"name": "John", "age": 30}';
|
||||
|
||||
await parser.parse(malformedJson);
|
||||
|
||||
expect(superParseSpy).toHaveBeenCalledWith(malformedJson);
|
||||
superParseSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle JSON with surrounding text by using parent parser', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithText = 'Here is the result: {"result": "success", "code": 200}';
|
||||
|
||||
// Mock the parent class parse method
|
||||
const mockParsedResult = { result: 'success', code: 200 };
|
||||
const superParseSpy = jest
|
||||
.spyOn(JsonOutputParser.prototype, 'parse')
|
||||
.mockResolvedValue(mockParsedResult);
|
||||
|
||||
const result = await parser.parse(jsonWithText);
|
||||
|
||||
expect(superParseSpy).toHaveBeenCalledWith(jsonWithText);
|
||||
expect(result).toEqual(mockParsedResult);
|
||||
|
||||
superParseSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should correctly parse JSON with markdown text inside properties', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithMarkdown = `{
|
||||
"title": "Markdown Guide",
|
||||
"content": "# Heading 1\\n## Heading 2\\n* Bullet point\\n* Another bullet\\n\\n\`\`\`code block\`\`\`\\n> Blockquote",
|
||||
"description": "A guide with **bold** and *italic* text"
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithMarkdown);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Markdown Guide',
|
||||
content:
|
||||
'# Heading 1\n## Heading 2\n* Bullet point\n* Another bullet\n\n```code block```\n> Blockquote',
|
||||
description: 'A guide with **bold** and *italic* text',
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly parse JSON with markdown code blocks containing JSON', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithMarkdownAndNestedJson = `{
|
||||
"title": "JSON Examples",
|
||||
"examples": "Here's an example of JSON: \`\`\`json\\n{\\"nested\\": \\"json\\", \\"in\\": \\"code block\\"}\\n\`\`\`",
|
||||
"valid": true
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithMarkdownAndNestedJson);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'JSON Examples',
|
||||
examples:
|
||||
'Here\'s an example of JSON: ```json\n{"nested": "json", "in": "code block"}\n```',
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON with special characters in markdown content', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithSpecialChars = `{
|
||||
"title": "Special Characters",
|
||||
"content": "# Testing \\n\\n * List with **bold** & *italic*\\n * Item with [link](https://example.com)\\n * Math: 2 < 3 > 1 && true || false",
|
||||
"technical": "function test() { return x < y && z > w; }"
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithSpecialChars);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Special Characters',
|
||||
content:
|
||||
'# Testing \n\n * List with **bold** & *italic*\n * Item with [link](https://example.com)\n * Math: 2 < 3 > 1 && true || false',
|
||||
technical: 'function test() { return x < y && z > w; }',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeChain', () => {
|
||||
it('should execute a simple chain without output parsers', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
});
|
||||
|
||||
expect(promptUtils.createPromptTemplate).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
messages: undefined,
|
||||
query: 'Hello',
|
||||
});
|
||||
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeStringOutputParserMock).toHaveBeenCalledWith(expect.any(StringOutputParser));
|
||||
expect(withConfigMock).toHaveBeenCalledWith(expect.any(Object));
|
||||
|
||||
expect(result).toEqual(['Test response']);
|
||||
|
||||
expect(tracing.getTracingConfig).toHaveBeenCalledWith(mockContext);
|
||||
});
|
||||
|
||||
it('should execute a chain with a single output parser', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'Test response' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mock<N8nOutputParser>(),
|
||||
});
|
||||
|
||||
expect(promptUtils.createPromptTemplate).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
messages: undefined,
|
||||
query: 'Hello',
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ result: 'Test response' }]);
|
||||
});
|
||||
|
||||
it('should wrap non-array responses in an array', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockOutputParser = mock<N8nOutputParser>();
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'Test response' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toEqual([{ result: 'Test response' }]);
|
||||
});
|
||||
|
||||
it('should pass the execution cancel signal to the chain', async () => {
|
||||
// For this test, we'll just verify that getExecutionCancelSignal is called
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
});
|
||||
|
||||
expect(mockContext.getExecutionCancelSignal).toHaveBeenCalled();
|
||||
expect(mockChain.invoke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support chat models', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const mockChatPromptTemplate = ChatPromptTemplate.fromMessages([]);
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test chat response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockChatPromptTemplate.pipe = pipeMock;
|
||||
fakeChatModel.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockChatPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeChatModel,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['Test chat response']);
|
||||
});
|
||||
|
||||
it('should use JsonOutputParser for OpenAI models with json_object response format', async () => {
|
||||
const fakeOpenAIModel = new FakeChatModel({});
|
||||
(
|
||||
fakeOpenAIModel as unknown as { modelKwargs: { response_format: { type: string } } }
|
||||
).modelKwargs = {
|
||||
response_format: { type: 'json_object' },
|
||||
};
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('{"result": "json data"}'),
|
||||
};
|
||||
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeOpenAIModel,
|
||||
});
|
||||
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(expect.any(JsonOutputParser));
|
||||
});
|
||||
|
||||
it('should use JsonOutputParser for Ollama models with json format', async () => {
|
||||
const fakeOllamaModel = new FakeChatModel({});
|
||||
(fakeOllamaModel as unknown as { format: string }).format = 'json';
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('{"result": "json data"}'),
|
||||
};
|
||||
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeOllamaModel,
|
||||
});
|
||||
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(expect.any(JsonOutputParser));
|
||||
});
|
||||
|
||||
it('should use getAgentStepsParser for version 1.9+ when output parser is provided', async () => {
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.9,
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const mockOutputParser = mock<N8nOutputParser>({
|
||||
getFormatInstructions: jest.fn().mockReturnValue('Format as JSON'),
|
||||
});
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockAgentStepsParser = jest.fn().mockResolvedValue({ result: 'parsed' });
|
||||
(promptUtils.getAgentStepsParser as jest.Mock).mockReturnValue(mockAgentStepsParser);
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'parsed' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeAgentStepsParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeAgentStepsParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(promptUtils.getAgentStepsParser).toHaveBeenCalledWith(mockOutputParser);
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeAgentStepsParserMock).toHaveBeenCalledWith(mockAgentStepsParser);
|
||||
});
|
||||
|
||||
it('should use direct output parser for versions < 1.9 when output parser is provided', async () => {
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.8,
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const mockOutputParser = mock<N8nOutputParser>({
|
||||
getFormatInstructions: jest.fn().mockReturnValue('Format as JSON'),
|
||||
});
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'parsed' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(promptUtils.getAgentStepsParser).not.toHaveBeenCalled();
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(mockOutputParser);
|
||||
});
|
||||
|
||||
it('should handle fallback LLM with built-in tools', async () => {
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const fakeFallbackLLM = new FakeChatModel({});
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
fakeLLM.withFallbacks = jest.fn().mockReturnValue(fakeLLM);
|
||||
fakeFallbackLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
fallbackLlm: fakeFallbackLLM,
|
||||
});
|
||||
|
||||
expect(fakeLLM.withFallbacks).toHaveBeenCalledWith([fakeFallbackLLM]);
|
||||
expect(pipeMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { getInputs, nodeProperties } from '../methods/config';
|
||||
|
||||
describe('config', () => {
|
||||
describe('getInputs', () => {
|
||||
it('should return basic inputs for all parameters', () => {
|
||||
const inputs = getInputs({});
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should exclude the OutputParser when hasOutputParser is false', () => {
|
||||
const inputs = getInputs({ hasOutputParser: false });
|
||||
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
});
|
||||
|
||||
it('should include the OutputParser when hasOutputParser is true', () => {
|
||||
const inputs = getInputs({ hasOutputParser: true });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should exclude the FallbackInput when needsFallback is false', () => {
|
||||
const inputs = getInputs({ hasOutputParser: true, needsFallback: false });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should include the FallbackInput when needsFallback is true', () => {
|
||||
const inputs = getInputs({ hasOutputParser: false, needsFallback: true });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeProperties', () => {
|
||||
it('should have the expected properties', () => {
|
||||
expect(Array.isArray(nodeProperties)).toBe(true);
|
||||
expect(nodeProperties.length).toBeGreaterThan(0);
|
||||
|
||||
const promptParams = nodeProperties.filter((prop) => prop.name === 'prompt');
|
||||
expect(promptParams.length).toBeGreaterThan(0);
|
||||
|
||||
const messagesParam = nodeProperties.find((prop) => prop.name === 'messages');
|
||||
expect(messagesParam).toBeDefined();
|
||||
expect(messagesParam?.type).toBe('fixedCollection');
|
||||
|
||||
const hasOutputParserParam = nodeProperties.find((prop) => prop.name === 'hasOutputParser');
|
||||
expect(hasOutputParserParam).toBeDefined();
|
||||
expect(hasOutputParserParam?.type).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, IBinaryData, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createImageMessage,
|
||||
dataUriFromImageData,
|
||||
UnsupportedMimeTypeError,
|
||||
} from '../methods/imageUtils';
|
||||
import type { MessageTemplate } from '../methods/types';
|
||||
|
||||
// Mock ChatGoogleGenerativeAI and ChatOllama
|
||||
jest.mock('@langchain/google-genai', () => ({
|
||||
ChatGoogleGenerativeAI: class MockChatGoogleGenerativeAI {},
|
||||
}));
|
||||
|
||||
jest.mock('@langchain/ollama', () => ({
|
||||
ChatOllama: class MockChatOllama {},
|
||||
}));
|
||||
|
||||
// Create a better mock for IExecuteFunctions that includes helpers
|
||||
const createMockExecuteFunctions = () => {
|
||||
const mockExec = mock<IExecuteFunctions>();
|
||||
// Add missing helpers property with mocked getBinaryDataBuffer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
mockExec.helpers = {
|
||||
getBinaryDataBuffer: jest.fn().mockResolvedValue(Buffer.from('Test image data')),
|
||||
} as any;
|
||||
return mockExec;
|
||||
};
|
||||
|
||||
describe('imageUtils', () => {
|
||||
describe('dataUriFromImageData', () => {
|
||||
it('should convert image data to data URI', () => {
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const mockBinaryData = mock<IBinaryData>({ mimeType: 'image/jpeg' });
|
||||
|
||||
const dataUri = dataUriFromImageData(mockBinaryData, mockBuffer);
|
||||
expect(dataUri).toBe('data:image/jpeg;base64,VGVzdCBkYXRh');
|
||||
});
|
||||
|
||||
it('should throw UnsupportedMimeTypeError for non-images', () => {
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const mockBinaryData = mock<IBinaryData>({ mimeType: 'text/plain' });
|
||||
|
||||
expect(() => {
|
||||
dataUriFromImageData(mockBinaryData, mockBuffer);
|
||||
}).toThrow(UnsupportedMimeTypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createImageMessage', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
let mockBuffer: Buffer;
|
||||
let mockBinaryData: jest.Mocked<IBinaryData>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockExecuteFunctions();
|
||||
mockBuffer = Buffer.from('Test image data');
|
||||
mockBinaryData = mock<IBinaryData>({ mimeType: 'image/png' });
|
||||
|
||||
// Mock required methods
|
||||
mockContext.getInputData.mockReturnValue([{ binary: { data: mockBinaryData }, json: {} }]);
|
||||
(mockContext.helpers.getBinaryDataBuffer as jest.Mock).mockResolvedValue(mockBuffer);
|
||||
mockContext.getInputConnectionData.mockResolvedValue({});
|
||||
mockContext.getNode.mockReturnValue({ name: 'TestNode' } as INode);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid message type', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'text', // Invalid for this test case
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle image URL messages', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
imageDetail: 'high',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'https://example.com/image.jpg',
|
||||
detail: 'high',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image URL messages with auto detail', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
imageDetail: 'auto',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'https://example.com/image.jpg',
|
||||
detail: undefined, // Auto becomes undefined
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw an error when binary data is missing', async () => {
|
||||
// Set up missing binary data
|
||||
mockContext.getInputData.mockReturnValue([{ json: {} }]); // No binary data
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow('No binary data set.');
|
||||
});
|
||||
|
||||
it('should handle binary image data for regular models', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
imageDetail: 'low',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
detail: 'low',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image data differently for GoogleGenerativeAI models', async () => {
|
||||
// Mock a Google model - using our mocked class
|
||||
mockContext.getInputConnectionData.mockResolvedValue(
|
||||
new ChatGoogleGenerativeAI({
|
||||
model: 'gemini-2.5-flash',
|
||||
}),
|
||||
);
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image data differently for Ollama models', async () => {
|
||||
// Mock an Ollama model - using our mocked class
|
||||
mockContext.getInputConnectionData.mockResolvedValue(new ChatOllama());
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pass through UnsupportedMimeTypeError', async () => {
|
||||
// Mock a non-image mime type
|
||||
mockBinaryData.mimeType = 'application/pdf';
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import type { AgentAction, AgentFinish } from '@langchain/core/agents';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate, PromptTemplate } from '@langchain/core/prompts';
|
||||
import { FakeLLM, FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import type { N8nStructuredOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import * as imageUtils from '../methods/imageUtils';
|
||||
import { createPromptTemplate, getAgentStepsParser } from '../methods/promptUtils';
|
||||
import type { MessageTemplate } from '../methods/types';
|
||||
|
||||
jest.mock('../methods/imageUtils', () => ({
|
||||
createImageMessage: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('promptUtils', () => {
|
||||
describe('createPromptTemplate', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<IExecuteFunctions>();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a simple prompt template for non-chat models', async () => {
|
||||
const fakeLLM = new FakeLLM({});
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(PromptTemplate);
|
||||
expect(result.inputVariables).toContain('query');
|
||||
});
|
||||
|
||||
it('should create a prompt template with format instructions', async () => {
|
||||
const fakeLLM = new FakeLLM({});
|
||||
const formatInstructions = 'Format your response as JSON';
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
formatInstructions,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(PromptTemplate);
|
||||
expect(result.inputVariables).toContain('query');
|
||||
|
||||
// Check that format instructions are included in the template
|
||||
const formattedResult = await result.format({ query: 'Test' });
|
||||
expect(formattedResult).toContain(formatInstructions);
|
||||
});
|
||||
|
||||
it('should create a chat prompt template for chat models', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(ChatPromptTemplate);
|
||||
});
|
||||
|
||||
it('should process text messages correctly', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'SystemMessagePromptTemplate',
|
||||
message: 'You are a helpful assistant',
|
||||
messageType: 'text',
|
||||
},
|
||||
{
|
||||
type: 'AIMessagePromptTemplate',
|
||||
message: 'How can I help you?',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(ChatPromptTemplate);
|
||||
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
expect(formattedMessages).toHaveLength(3); // 2 messages + 1 query
|
||||
expect(formattedMessages[0].content).toBe('You are a helpful assistant');
|
||||
expect(formattedMessages[1].content).toBe('How can I help you?');
|
||||
});
|
||||
|
||||
it('should escape curly braces in messages', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'SystemMessagePromptTemplate',
|
||||
message: 'You are a {helpful} assistant',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
|
||||
// Validate the messages have escaped curly braces
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
expect(formattedMessages[0].content).toBe('You are a {helpful} assistant');
|
||||
});
|
||||
|
||||
it('should handle image messages by calling createImageMessage', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const imageMessage: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
};
|
||||
|
||||
// Mock the image message creation
|
||||
const mockHumanMessage = new HumanMessage({
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }],
|
||||
});
|
||||
(imageUtils.createImageMessage as jest.Mock).mockResolvedValue(mockHumanMessage);
|
||||
|
||||
await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages: [imageMessage],
|
||||
query: 'Describe this image',
|
||||
});
|
||||
|
||||
expect(imageUtils.createImageMessage).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message: imageMessage,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error for invalid message types', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'InvalidMessageType',
|
||||
message: 'This is an invalid message',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Test query',
|
||||
}),
|
||||
).rejects.toThrow(OperationalError);
|
||||
});
|
||||
|
||||
it('should add the query to an existing human message with content if it exists', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
|
||||
// Create a mock image message with content array
|
||||
const mockHumanMessage = new HumanMessage({
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }],
|
||||
});
|
||||
(imageUtils.createImageMessage as jest.Mock).mockResolvedValue(mockHumanMessage);
|
||||
|
||||
const imageMessage: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
};
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages: [imageMessage],
|
||||
query: 'Describe this image',
|
||||
});
|
||||
|
||||
// Format the message and check that the query was added to the existing content
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Describe this image',
|
||||
});
|
||||
expect(formattedMessages).toHaveLength(1);
|
||||
|
||||
// The content should now have the original image and the text query
|
||||
const content = formattedMessages[0].content as any[];
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0].type).toBe('image_url');
|
||||
expect(content[1].type).toBe('text');
|
||||
expect(content[1].text).toContain('Describe this image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentStepsParser', () => {
|
||||
let mockOutputParser: jest.Mocked<N8nStructuredOutputParser>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOutputParser = mock<N8nStructuredOutputParser>({
|
||||
parse: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse string input directly', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const parsedResult = { result: 'success' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser('{"result": "success"}');
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('{"result": "success"}');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse format_final_json_response tool input', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const toolInput = { city: 'Berlin', temperature: 15 };
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'format_final_json_response',
|
||||
toolInput,
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsedResult = { city: 'Berlin', temperature: 15 };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(JSON.stringify(toolInput));
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should handle format_final_json_response with string tool input', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const toolInput = 'simple string';
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'format_final_json_response',
|
||||
toolInput,
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsedResult = { text: 'simple string' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('simple string');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with text property', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({ content: [{ type: 'text', text: 'Hello world' }] });
|
||||
|
||||
const parsedResult = { message: 'Hello world' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Hello world');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with content array', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } },
|
||||
],
|
||||
});
|
||||
|
||||
const parsedResult = { message: 'Hello' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Hello');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with string content', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({ content: 'Simple text content' });
|
||||
|
||||
const parsedResult = { content: 'Simple text content' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Simple text content');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse AgentFinish returnValues', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const agentFinish: AgentFinish = {
|
||||
returnValues: { output: 'Final answer', status: 'success' },
|
||||
log: 'Finished',
|
||||
};
|
||||
|
||||
const parsedResult = { output: 'Final answer', status: 'success' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(agentFinish);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(JSON.stringify(agentFinish.returnValues));
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should handle array of agent actions without format_final_json_response', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const steps: AgentAction[] = [
|
||||
{ tool: 'search', toolInput: { query: 'test' }, log: '' },
|
||||
{ tool: 'calculator', toolInput: { expression: '1+1' }, log: '' },
|
||||
];
|
||||
|
||||
await expect(parser(steps)).rejects.toThrow('Failed to parse agent steps');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { formatResponse } from '../methods/responseFormatter';
|
||||
|
||||
describe('responseFormatter', () => {
|
||||
describe('formatResponse', () => {
|
||||
it('should format string responses', () => {
|
||||
const result = formatResponse('Test response', true);
|
||||
expect(result).toEqual({
|
||||
text: 'Test response',
|
||||
});
|
||||
});
|
||||
|
||||
it('should trim string responses', () => {
|
||||
const result = formatResponse(' Test response with whitespace ', true);
|
||||
expect(result).toEqual({
|
||||
text: 'Test response with whitespace',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array responses', () => {
|
||||
const testArray = [{ item: 1 }, { item: 2 }];
|
||||
const result = formatResponse(testArray, true);
|
||||
expect(result).toEqual({ data: testArray });
|
||||
});
|
||||
|
||||
it('should handle object responses when unwrapping is enabled', () => {
|
||||
const testObject = { key: 'value', nested: { key: 'value' } };
|
||||
const result = formatResponse(testObject, true);
|
||||
expect(result).toEqual(testObject);
|
||||
});
|
||||
|
||||
it('should stringify object responses when unwrapping is disabled', () => {
|
||||
const testObject = { key: 'value', nested: { key: 'value' } };
|
||||
const result = formatResponse(testObject, false);
|
||||
expect(result).toEqual({
|
||||
text: JSON.stringify(testObject),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle primitive non-string responses', () => {
|
||||
const testNumber = 42;
|
||||
const result = formatResponse(testNumber, true);
|
||||
expect(result).toEqual({
|
||||
response: {
|
||||
text: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex object structures when unwrapping is enabled', () => {
|
||||
const complexObject = {
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
address: {
|
||||
street: '123 Main St',
|
||||
city: 'Anytown',
|
||||
},
|
||||
},
|
||||
items: [1, 2, 3],
|
||||
active: true,
|
||||
};
|
||||
|
||||
const result = formatResponse(complexObject, true);
|
||||
expect(result).toEqual(complexObject);
|
||||
});
|
||||
|
||||
it('should stringify complex object structures when unwrapping is disabled', () => {
|
||||
const complexObject = {
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
address: {
|
||||
street: '123 Main St',
|
||||
city: 'Anytown',
|
||||
},
|
||||
},
|
||||
items: [1, 2, 3],
|
||||
active: true,
|
||||
};
|
||||
|
||||
const result = formatResponse(complexObject, false);
|
||||
expect(result).toEqual({
|
||||
text: JSON.stringify(complexObject),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user