pull:初次提交

This commit is contained in:
Yep_Q
2025-09-08 04:48:28 +08:00
parent 5c0619656d
commit f64f498365
11751 changed files with 1953723 additions and 0 deletions

View File

@@ -0,0 +1,528 @@
import { DynamicTool, type Tool } from '@langchain/core/tools';
import { Toolkit } from 'langchain/agents';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import { NodeOperationError } from 'n8n-workflow';
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
import { z } from 'zod';
import {
escapeSingleCurlyBrackets,
getConnectedTools,
hasLongSequentialRepeat,
unwrapNestedOutput,
getSessionId,
} from '../helpers';
import { N8nTool } from '../N8nTool';
describe('escapeSingleCurlyBrackets', () => {
it('should return undefined when input is undefined', () => {
expect(escapeSingleCurlyBrackets(undefined)).toBeUndefined();
});
it('should escape single curly brackets', () => {
expect(escapeSingleCurlyBrackets('Hello {world}')).toBe('Hello {{world}}');
expect(escapeSingleCurlyBrackets('Test {value} here')).toBe('Test {{value}} here');
});
it('should not escape already double curly brackets', () => {
expect(escapeSingleCurlyBrackets('Hello {{world}}')).toBe('Hello {{world}}');
expect(escapeSingleCurlyBrackets('Test {{value}} here')).toBe('Test {{value}} here');
});
it('should handle mixed single and double curly brackets', () => {
expect(escapeSingleCurlyBrackets('Hello {{world}} and {earth}')).toBe(
'Hello {{world}} and {{earth}}',
);
});
it('should handle empty string', () => {
expect(escapeSingleCurlyBrackets('')).toBe('');
});
it('should handle string with no curly brackets', () => {
expect(escapeSingleCurlyBrackets('Hello world')).toBe('Hello world');
});
it('should handle string with only opening curly bracket', () => {
expect(escapeSingleCurlyBrackets('Hello { world')).toBe('Hello {{ world');
});
it('should handle string with only closing curly bracket', () => {
expect(escapeSingleCurlyBrackets('Hello world }')).toBe('Hello world }}');
});
it('should handle string with multiple single curly brackets', () => {
expect(escapeSingleCurlyBrackets('{Hello} {world}')).toBe('{{Hello}} {{world}}');
});
it('should handle string with alternating single and double curly brackets', () => {
expect(escapeSingleCurlyBrackets('{a} {{b}} {c} {{d}}')).toBe('{{a}} {{b}} {{c}} {{d}}');
});
it('should handle string with curly brackets at the start and end', () => {
expect(escapeSingleCurlyBrackets('{start} middle {end}')).toBe('{{start}} middle {{end}}');
});
it('should handle string with special characters', () => {
expect(escapeSingleCurlyBrackets('Special {!@#$%^&*} chars')).toBe(
'Special {{!@#$%^&*}} chars',
);
});
it('should handle string with numbers in curly brackets', () => {
expect(escapeSingleCurlyBrackets('Numbers {123} here')).toBe('Numbers {{123}} here');
});
it('should handle string with whitespace in curly brackets', () => {
expect(escapeSingleCurlyBrackets('Whitespace { } here')).toBe('Whitespace {{ }} here');
});
it('should handle multi-line input with single curly brackets', () => {
const input = `
Line 1 {test}
Line 2 {another test}
Line 3
`;
const expected = `
Line 1 {{test}}
Line 2 {{another test}}
Line 3
`;
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
});
it('should handle multi-line input with mixed single and double curly brackets', () => {
const input = `
{Line 1}
{{Line 2}}
Line {3} {{4}}
`;
const expected = `
{{Line 1}}
{{Line 2}}
Line {{3}} {{4}}
`;
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
});
it('should handle multi-line input with curly brackets at line starts and ends', () => {
const input = `
{Start of line 1
End of line 2}
{3} Line 3 {3}
`;
const expected = `
{{Start of line 1
End of line 2}}
{{3}} Line 3 {{3}}
`;
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
});
it('should handle multi-line input with nested curly brackets', () => {
const input = `
Outer {
Inner {nested}
}
`;
const expected = `
Outer {{
Inner {{nested}}
}}
`;
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
});
it('should handle string with triple uneven curly brackets - opening', () => {
expect(escapeSingleCurlyBrackets('Hello {{{world}')).toBe('Hello {{{{world}}');
});
it('should handle string with triple uneven curly brackets - closing', () => {
expect(escapeSingleCurlyBrackets('Hello world}}}')).toBe('Hello world}}}}');
});
it('should handle string with triple uneven curly brackets - mixed opening and closing', () => {
expect(escapeSingleCurlyBrackets('{{{Hello}}} {world}}}')).toBe('{{{{Hello}}}} {{world}}}}');
});
it('should handle string with triple uneven curly brackets - multiple occurrences', () => {
expect(escapeSingleCurlyBrackets('{{{a}}} {{b}}} {{{c}')).toBe('{{{{a}}}} {{b}}}} {{{{c}}');
});
it('should handle multi-line input with triple uneven curly brackets', () => {
const input = `
{{{Line 1}
Line 2}}}
{{{3}}} Line 3 {{{4
`;
const expected = `
{{{{Line 1}}
Line 2}}}}
{{{{3}}}} Line 3 {{{{4
`;
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
});
});
describe('getConnectedTools', () => {
let mockExecuteFunctions: IExecuteFunctions;
let mockNode: INode;
let mockN8nTool: N8nTool;
beforeEach(() => {
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
};
mockExecuteFunctions = createMockExecuteFunction({}, mockNode);
mockN8nTool = new N8nTool(mockExecuteFunctions as unknown as ISupplyDataFunctions, {
name: 'Dummy Tool',
description: 'A dummy tool for testing',
func: jest.fn(),
schema: z.object({
foo: z.string(),
}),
});
});
it('should return empty array when no tools are connected', async () => {
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([]);
const tools = await getConnectedTools(mockExecuteFunctions, true);
expect(tools).toEqual([]);
});
it('should return tools without modification when enforceUniqueNames is false', async () => {
const mockTools = [
{ name: 'tool1', description: 'desc1' },
{ name: 'tool1', description: 'desc2' }, // Duplicate name
];
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
const tools = await getConnectedTools(mockExecuteFunctions, false);
expect(tools).toEqual(mockTools);
});
it('should throw error when duplicate tool names exist and enforceUniqueNames is true', async () => {
const mockTools = [
{ name: 'tool1', description: 'desc1' },
{ name: 'tool1', description: 'desc2' },
];
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
await expect(getConnectedTools(mockExecuteFunctions, true)).rejects.toThrow(NodeOperationError);
});
it('should escape curly brackets in tool descriptions when escapeCurlyBrackets is true', async () => {
const mockTools = [{ name: 'tool1', description: 'Test {value}' }] as Tool[];
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
const tools = await getConnectedTools(mockExecuteFunctions, true, false, true);
expect(tools[0].description).toBe('Test {{value}}');
});
it('should convert N8nTool to dynamic tool when convertStructuredTool is true', async () => {
const mockDynamicTool = new DynamicTool({
name: 'dynamicTool',
description: 'desc',
func: jest.fn(),
});
const asDynamicToolSpy = jest.fn().mockReturnValue(mockDynamicTool);
mockN8nTool.asDynamicTool = asDynamicToolSpy;
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]);
const tools = await getConnectedTools(mockExecuteFunctions, true, true);
expect(asDynamicToolSpy).toHaveBeenCalled();
expect(tools[0]).toEqual(mockDynamicTool);
});
it('should not convert N8nTool when convertStructuredTool is false', async () => {
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]);
const tools = await getConnectedTools(mockExecuteFunctions, true, false);
expect(tools[0]).toBe(mockN8nTool);
});
it('should flatten tools from a toolkit', async () => {
class MockToolkit extends Toolkit {
tools: Tool[];
constructor(tools: unknown[]) {
super();
this.tools = tools as Tool[];
}
}
const mockTools = [
{ name: 'tool1', description: 'desc1' },
new MockToolkit([
{ name: 'toolkitTool1', description: 'toolkitToolDesc1' },
{ name: 'toolkitTool2', description: 'toolkitToolDesc2' },
]),
];
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
const tools = await getConnectedTools(mockExecuteFunctions, false);
expect(tools).toEqual([
{ name: 'tool1', description: 'desc1' },
{ name: 'toolkitTool1', description: 'toolkitToolDesc1' },
{ name: 'toolkitTool2', description: 'toolkitToolDesc2' },
]);
});
});
describe('unwrapNestedOutput', () => {
it('should unwrap doubly nested output', () => {
const input = {
output: {
output: {
text: 'Hello world',
confidence: 0.95,
},
},
};
const expected = {
output: {
text: 'Hello world',
confidence: 0.95,
},
};
expect(unwrapNestedOutput(input)).toEqual(expected);
});
it('should not modify regular output object', () => {
const input = {
output: {
text: 'Hello world',
confidence: 0.95,
},
};
expect(unwrapNestedOutput(input)).toEqual(input);
});
it('should not modify object without output property', () => {
const input = {
result: 'success',
data: {
text: 'Hello world',
},
};
expect(unwrapNestedOutput(input)).toEqual(input);
});
it('should not modify when output is not an object', () => {
const input = {
output: 'Hello world',
};
expect(unwrapNestedOutput(input)).toEqual(input);
});
it('should not modify when object has multiple properties', () => {
const input = {
output: {
output: {
text: 'Hello world',
},
},
meta: {
timestamp: 123456789,
},
};
expect(unwrapNestedOutput(input)).toEqual(input);
});
it('should not modify when inner output has multiple properties', () => {
const input = {
output: {
output: {
text: 'Hello world',
},
meta: {
timestamp: 123456789,
},
},
};
expect(unwrapNestedOutput(input)).toEqual(input);
});
it('should handle null values properly', () => {
const input = {
output: null,
};
expect(unwrapNestedOutput(input)).toEqual(input);
});
it('should handle empty object values properly', () => {
const input = {
output: {},
};
expect(unwrapNestedOutput(input)).toEqual(input);
});
});
describe('getSessionId', () => {
let mockCtx: any;
beforeEach(() => {
mockCtx = {
getNodeParameter: jest.fn(),
evaluateExpression: jest.fn(),
getChatTrigger: jest.fn(),
getNode: jest.fn(),
};
});
it('should retrieve sessionId from bodyData', () => {
mockCtx.getBodyData = jest.fn();
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.getBodyData.mockReturnValue({ sessionId: '12345' });
const sessionId = getSessionId(mockCtx, 0);
expect(sessionId).toBe('12345');
});
it('should retrieve sessionId from chat trigger', () => {
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.evaluateExpression.mockReturnValueOnce(undefined);
mockCtx.getChatTrigger.mockReturnValue({ name: 'chatTrigger' });
mockCtx.evaluateExpression.mockReturnValueOnce('67890');
const sessionId = getSessionId(mockCtx, 0);
expect(sessionId).toBe('67890');
});
it('should throw error if sessionId is not found', () => {
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.evaluateExpression.mockReturnValue(undefined);
mockCtx.getChatTrigger.mockReturnValue(undefined);
expect(() => getSessionId(mockCtx, 0)).toThrow(NodeOperationError);
});
it('should use custom sessionId if provided', () => {
mockCtx.getNodeParameter.mockReturnValueOnce('custom').mockReturnValueOnce('customSessionId');
const sessionId = getSessionId(mockCtx, 0);
expect(sessionId).toBe('customSessionId');
});
});
describe('hasLongSequentialRepeat', () => {
it('should return false for text shorter than threshold', () => {
const text = 'a'.repeat(99);
expect(hasLongSequentialRepeat(text, 100)).toBe(false);
});
it('should return false for normal text without repeats', () => {
const text = 'This is a normal text without many sequential repeating characters.';
expect(hasLongSequentialRepeat(text)).toBe(false);
});
it('should return true for text with exactly threshold repeats', () => {
const text = 'a'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should return true for text with more than threshold repeats', () => {
const text = 'b'.repeat(150);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should detect repeats in the middle of text', () => {
const text = 'Normal text ' + 'x'.repeat(100) + ' more normal text';
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should detect repeats at the end of text', () => {
const text = 'Normal text at the beginning' + 'z'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should work with different thresholds', () => {
const text = 'a'.repeat(50);
expect(hasLongSequentialRepeat(text, 30)).toBe(true);
expect(hasLongSequentialRepeat(text, 60)).toBe(false);
});
it('should handle special characters', () => {
const text = '.'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should handle spaces', () => {
const text = ' '.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should handle newlines', () => {
const text = '\n'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should not detect non-sequential repeats', () => {
const text = 'ababab'.repeat(50); // 300 chars but no sequential repeats
expect(hasLongSequentialRepeat(text, 100)).toBe(false);
});
it('should handle mixed content with repeats below threshold', () => {
const text = 'aaa' + 'b'.repeat(50) + 'ccc' + 'd'.repeat(40) + 'eee';
expect(hasLongSequentialRepeat(text, 100)).toBe(false);
});
it('should handle empty string', () => {
expect(hasLongSequentialRepeat('', 100)).toBe(false);
});
it('should work with very large texts', () => {
const normalText = 'Lorem ipsum dolor sit amet '.repeat(1000);
const textWithRepeat = normalText + 'A'.repeat(100) + normalText;
expect(hasLongSequentialRepeat(textWithRepeat, 100)).toBe(true);
});
it('should detect unicode character repeats', () => {
const text = '😀'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
describe('error handling', () => {
it('should handle null input', () => {
expect(hasLongSequentialRepeat(null as any)).toBe(false);
});
it('should handle undefined input', () => {
expect(hasLongSequentialRepeat(undefined as any)).toBe(false);
});
it('should handle non-string input', () => {
expect(hasLongSequentialRepeat(123 as any)).toBe(false);
expect(hasLongSequentialRepeat({} as any)).toBe(false);
expect(hasLongSequentialRepeat([] as any)).toBe(false);
});
it('should handle zero or negative threshold', () => {
const text = 'a'.repeat(100);
expect(hasLongSequentialRepeat(text, 0)).toBe(false);
expect(hasLongSequentialRepeat(text, -1)).toBe(false);
});
it('should handle empty string', () => {
expect(hasLongSequentialRepeat('', 100)).toBe(false);
});
});
});

View File

@@ -0,0 +1,157 @@
import { ProxyAgent } from 'undici';
import { getProxyAgent } from '../httpProxyAgent';
// Mock the dependencies
jest.mock('undici', () => ({
ProxyAgent: jest.fn().mockImplementation((url) => ({ proxyUrl: url })),
}));
describe('getProxyAgent', () => {
// Store original environment variables
const originalEnv = { ...process.env };
// Reset environment variables before each test
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.HTTP_PROXY;
delete process.env.http_proxy;
delete process.env.HTTPS_PROXY;
delete process.env.https_proxy;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
});
// Restore original environment after all tests
afterAll(() => {
process.env = originalEnv;
});
describe('target URL not provided', () => {
it('should return undefined when no proxy environment variables are set', () => {
const agent = getProxyAgent();
expect(agent).toBeUndefined();
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should create ProxyAgent when HTTPS_PROXY is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const agent = getProxyAgent();
expect(agent).toEqual({ proxyUrl });
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
});
it('should create ProxyAgent when https_proxy is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.https_proxy = proxyUrl;
const agent = getProxyAgent();
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
expect(agent).toEqual({ proxyUrl });
});
it('should respect priority order of proxy environment variables', () => {
// Set multiple proxy environment variables
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.http_proxy = 'http://http-proxy-lowercase.example.com:8080';
process.env.HTTPS_PROXY = 'https://https-proxy.example.com:8080';
process.env.https_proxy = 'https://https-proxy-lowercase.example.com:8080';
const agent = getProxyAgent();
// Should use https_proxy as it has highest priority now
expect(ProxyAgent).toHaveBeenCalledWith('https://https-proxy-lowercase.example.com:8080');
expect(agent).toEqual({ proxyUrl: 'https://https-proxy-lowercase.example.com:8080' });
});
});
describe('target URL provided', () => {
it('should return undefined when no proxy is configured', () => {
const agent = getProxyAgent('https://api.openai.com/v1');
expect(agent).toBeUndefined();
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should create ProxyAgent for HTTPS URL when HTTPS_PROXY is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const agent = getProxyAgent('https://api.openai.com/v1');
expect(agent).toEqual({ proxyUrl });
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
});
it('should create ProxyAgent for HTTP URL when HTTP_PROXY is set', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
const agent = getProxyAgent('http://api.example.com');
expect(agent).toEqual({ proxyUrl });
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
});
it('should use HTTPS_PROXY for HTTPS URLs even when HTTP_PROXY is set', () => {
const httpProxy = 'http://http-proxy.example.com:8080';
const httpsProxy = 'https://https-proxy.example.com:8443';
process.env.HTTP_PROXY = httpProxy;
process.env.HTTPS_PROXY = httpsProxy;
const agent = getProxyAgent('https://api.openai.com/v1');
expect(agent).toEqual({ proxyUrl: httpsProxy });
expect(ProxyAgent).toHaveBeenCalledWith(httpsProxy);
});
it('should respect NO_PROXY for localhost', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1';
const agent = getProxyAgent('http://localhost:3000');
expect(agent).toBeUndefined();
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should respect NO_PROXY wildcard patterns', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = '*.internal.company.com,localhost';
const agent = getProxyAgent('https://api.internal.company.com');
expect(agent).toBeUndefined();
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should use proxy for URLs not in NO_PROXY', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1';
const agent = getProxyAgent('https://api.openai.com/v1');
expect(agent).toEqual({ proxyUrl });
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
});
it('should handle mixed case environment variables', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.https_proxy = proxyUrl;
process.env.no_proxy = 'localhost';
const agent = getProxyAgent('https://api.openai.com/v1');
expect(agent).toEqual({ proxyUrl });
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
});
});
});

View File

@@ -0,0 +1,381 @@
import type { JSONSchema7 } from 'json-schema';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import { NodeOperationError } from 'n8n-workflow';
import type { INode, IExecuteFunctions } from 'n8n-workflow';
import {
generateSchemaFromExample,
convertJsonSchemaToZod,
throwIfToolSchema,
} from './../schemaParsing';
const mockNode: INode = {
id: '1',
name: 'Mock node',
typeVersion: 1,
type: 'n8n-nodes-base.mock',
position: [60, 760],
parameters: {},
};
describe('generateSchemaFromExample', () => {
it('should generate schema from simple object', () => {
const example = JSON.stringify({
name: 'John',
age: 30,
active: true,
});
const schema = generateSchemaFromExample(example);
expect(schema).toMatchObject({
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
active: { type: 'boolean' },
},
});
});
it('should generate schema from nested object', () => {
const example = JSON.stringify({
user: {
profile: {
name: 'Jane',
email: 'jane@example.com',
},
preferences: {
theme: 'dark',
notifications: true,
},
},
});
const schema = generateSchemaFromExample(example);
expect(schema).toMatchObject({
type: 'object',
properties: {
user: {
type: 'object',
properties: {
profile: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string' },
},
},
preferences: {
type: 'object',
properties: {
theme: { type: 'string' },
notifications: { type: 'boolean' },
},
},
},
},
},
});
});
it('should generate schema from array', () => {
const example = JSON.stringify({
items: ['apple', 'banana', 'cherry'],
numbers: [1, 2, 3],
});
const schema = generateSchemaFromExample(example);
expect(schema).toMatchObject({
type: 'object',
properties: {
items: {
type: 'array',
items: { type: 'string' },
},
numbers: {
type: 'array',
items: { type: 'number' },
},
},
});
});
it('should generate schema from complex nested structure', () => {
const example = JSON.stringify({
metadata: {
version: '1.0.0',
tags: ['production', 'api'],
},
data: [
{
id: 1,
name: 'Item 1',
properties: {
color: 'red',
size: 'large',
},
},
],
});
const schema = generateSchemaFromExample(example);
expect(schema.type).toBe('object');
expect(schema.properties).toHaveProperty('metadata');
expect(schema.properties).toHaveProperty('data');
expect((schema.properties?.data as JSONSchema7).type).toBe('array');
expect(((schema.properties?.data as JSONSchema7).items as JSONSchema7).type).toBe('object');
});
it('should handle null values', () => {
const example = JSON.stringify({
name: 'John',
middleName: null,
age: 30,
});
const schema = generateSchemaFromExample(example);
expect(schema).toMatchObject({
type: 'object',
properties: {
name: { type: 'string' },
middleName: { type: 'null' },
age: { type: 'number' },
},
});
});
it('should not require fields by default', () => {
const example = JSON.stringify({
name: 'John',
age: 30,
});
const schema = generateSchemaFromExample(example);
expect(schema.required).toBeUndefined();
});
it('should make all fields required when allFieldsRequired is true', () => {
const example = JSON.stringify({
name: 'John',
age: 30,
active: true,
});
const schema = generateSchemaFromExample(example, true);
expect(schema.required).toEqual(['name', 'age', 'active']);
});
it('should make all nested fields required when allFieldsRequired is true', () => {
const example = JSON.stringify({
user: {
profile: {
name: 'Jane',
email: 'jane@example.com',
},
preferences: {
theme: 'dark',
notifications: true,
},
},
});
const schema = generateSchemaFromExample(example, true);
expect(schema.required).toEqual(['user']);
const userSchema = schema.properties?.user as JSONSchema7;
expect(userSchema.required).toEqual(['profile', 'preferences']);
expect((userSchema.properties?.profile as JSONSchema7).required).toEqual(['name', 'email']);
expect((userSchema.properties?.preferences as JSONSchema7).required).toEqual([
'theme',
'notifications',
]);
// Check the full structure
expect(schema).toMatchObject({
type: 'object',
properties: {
user: {
type: 'object',
properties: {
profile: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string' },
},
required: ['name', 'email'],
},
preferences: {
type: 'object',
properties: {
theme: { type: 'string' },
notifications: { type: 'boolean' },
},
required: ['theme', 'notifications'],
},
},
required: ['profile', 'preferences'],
},
},
required: ['user'],
});
});
it('should handle empty object', () => {
const example = JSON.stringify({});
const schema = generateSchemaFromExample(example);
expect(schema).toMatchObject({
type: 'object',
properties: {},
});
});
it('should handle empty object with allFieldsRequired true', () => {
const example = JSON.stringify({});
const schema = generateSchemaFromExample(example, true);
expect(schema).toMatchObject({
type: 'object',
properties: {},
});
});
it('should throw error for invalid JSON', () => {
const invalidJson = '{ name: "John", age: 30 }'; // Missing quotes around property names
expect(() => generateSchemaFromExample(invalidJson)).toThrow();
});
it('should handle array of objects', () => {
const example = JSON.stringify([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
]);
const schema = generateSchemaFromExample(example);
expect(schema).toMatchObject({
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
},
},
});
});
it('should handle array of objects with allFieldsRequired true', () => {
const example = JSON.stringify([
{ id: 1, name: 'Item 1', metadata: { tag: 'prod' } },
{ id: 2, name: 'Item 2', metadata: { tag: 'dev' } },
]);
const schema = generateSchemaFromExample(example, true);
expect(schema).toMatchObject({
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
metadata: {
type: 'object',
properties: {
tag: { type: 'string' },
},
required: ['tag'],
},
},
required: ['id', 'name', 'metadata'],
},
});
});
});
describe('convertJsonSchemaToZod', () => {
it('should convert simple object schema to zod', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name'],
};
const zodSchema = convertJsonSchemaToZod(schema);
expect(zodSchema).toBeDefined();
expect(typeof zodSchema.parse).toBe('function');
});
it('should convert and validate with zod schema', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name'],
};
const zodSchema = convertJsonSchemaToZod(schema);
// Valid data should pass
expect(() => zodSchema.parse({ name: 'John', age: 30 })).not.toThrow();
expect(() => zodSchema.parse({ name: 'John' })).not.toThrow();
// Invalid data should throw
expect(() => zodSchema.parse({ age: 30 })).toThrow(); // Missing required name
expect(() => zodSchema.parse({ name: 'John', age: 'thirty' })).toThrow(); // Wrong type for age
});
});
describe('throwIfToolSchema', () => {
it('should throw NodeOperationError for tool schema error', () => {
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
const error = new Error('tool input did not match expected schema');
expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError);
expect(() => throwIfToolSchema(ctx, error)).toThrow(/tool input did not match expected schema/);
expect(() => throwIfToolSchema(ctx, error)).toThrow(
/This is most likely because some of your tools are configured to require a specific schema/,
);
});
it('should not throw for non-tool schema errors', () => {
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
const error = new Error('Some other error');
expect(() => throwIfToolSchema(ctx, error)).not.toThrow();
});
it('should not throw for errors without message', () => {
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
const error = new Error();
expect(() => throwIfToolSchema(ctx, error)).not.toThrow();
});
it('should handle errors that are not Error instances', () => {
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
const error = { message: 'tool input did not match expected schema' } as Error;
expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError);
});
});

View File

@@ -0,0 +1,177 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-require-imports */
import type { TiktokenEncoding } from 'js-tiktoken/lite';
import { Tiktoken } from 'js-tiktoken/lite';
import { getEncoding, encodingForModel } from '../tokenizer/tiktoken';
jest.mock('js-tiktoken/lite', () => ({
Tiktoken: jest.fn(),
getEncodingNameForModel: jest.fn(),
}));
jest.mock('fs', () => ({
readFileSync: jest.fn(),
}));
jest.mock('n8n-workflow', () => ({
jsonParse: jest.fn(),
}));
describe('tiktoken utils', () => {
const mockReadFileSync = require('fs').readFileSync;
const mockJsonParse = require('n8n-workflow').jsonParse;
beforeEach(() => {
jest.clearAllMocks();
// Set up mock implementations
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes('cl100k_base.json')) {
return JSON.stringify({ mockCl100kBase: 'data' });
}
if (path.includes('o200k_base.json')) {
return JSON.stringify({ mockO200kBase: 'data' });
}
throw new Error(`Unexpected file path: ${path}`);
});
mockJsonParse.mockImplementation((content: string) => JSON.parse(content));
});
describe('getEncoding', () => {
it('should return Tiktoken instance for cl100k_base encoding', () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = getEncoding('cl100k_base');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should return Tiktoken instance for o200k_base encoding', () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = getEncoding('o200k_base');
expect(Tiktoken).toHaveBeenCalledWith({ mockO200kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should map p50k_base to cl100k_base encoding', () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = getEncoding('p50k_base');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should map r50k_base to cl100k_base encoding', () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = getEncoding('r50k_base');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should map gpt2 to cl100k_base encoding', () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = getEncoding('gpt2');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should map p50k_edit to cl100k_base encoding', () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = getEncoding('p50k_edit');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should return cl100k_base for unknown encoding', () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = getEncoding('unknown_encoding' as unknown as TiktokenEncoding);
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should use cache for repeated calls with same encoding', () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
// Clear any previous calls to isolate this test
jest.clearAllMocks();
// Use a unique encoding that hasn't been cached yet
const uniqueEncoding = 'test_encoding' as TiktokenEncoding;
// First call
const result1 = getEncoding(uniqueEncoding);
expect(Tiktoken).toHaveBeenCalledTimes(1);
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' }); // Falls back to cl100k_base
// Second call - should use cache
const result2 = getEncoding(uniqueEncoding);
expect(Tiktoken).toHaveBeenCalledTimes(1); // Still only called once
expect(result1).toBe(result2);
});
});
describe('encodingForModel', () => {
it('should call getEncodingNameForModel and return encoding for cl100k_base', () => {
const mockGetEncodingNameForModel = require('js-tiktoken/lite').getEncodingNameForModel;
const mockTiktoken = {};
mockGetEncodingNameForModel.mockReturnValue('cl100k_base');
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
// Clear previous calls since cl100k_base might be cached from previous tests
jest.clearAllMocks();
mockGetEncodingNameForModel.mockReturnValue('cl100k_base');
const result = encodingForModel('gpt-3.5-turbo');
expect(mockGetEncodingNameForModel).toHaveBeenCalledWith('gpt-3.5-turbo');
// Since cl100k_base was already loaded in previous tests, Tiktoken constructor
// won't be called again due to caching
expect(result).toBeTruthy();
});
it('should handle gpt-4 model with o200k_base', () => {
const mockGetEncodingNameForModel = require('js-tiktoken/lite').getEncodingNameForModel;
const mockTiktoken = { isO200k: true };
// Use o200k_base to test a different encoding
mockGetEncodingNameForModel.mockReturnValue('o200k_base');
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
// Clear mocks and set up for this test
jest.clearAllMocks();
mockGetEncodingNameForModel.mockReturnValue('o200k_base');
const result = encodingForModel('gpt-4');
expect(mockGetEncodingNameForModel).toHaveBeenCalledWith('gpt-4');
// Since o200k_base was already loaded in previous tests, we just verify the result
expect(result).toBeTruthy();
});
});
});