refactor(Structured Output Parser Node): Support schema via expression (#16671)

This commit is contained in:
oleg
2025-06-25 09:30:52 +02:00
committed by GitHub
parent 28aabd4044
commit d382070e3f
8 changed files with 667 additions and 15 deletions

View File

@@ -0,0 +1,120 @@
import { buildInputSchemaField } from './descriptions';
describe('buildInputSchemaField', () => {
it('should create input schema field with noDataExpression set to false', () => {
const result = buildInputSchemaField();
expect(result.noDataExpression).toBe(false);
expect(result.displayName).toBe('Input Schema');
expect(result.name).toBe('inputSchema');
expect(result.type).toBe('json');
});
it('should include typeOptions with rows set to 10', () => {
const result = buildInputSchemaField();
expect(result.typeOptions).toEqual({ rows: 10 });
});
it('should have correct default JSON schema', () => {
const result = buildInputSchemaField();
const expectedDefault = `{
"type": "object",
"properties": {
"some_input": {
"type": "string",
"description": "Some input to the function"
}
}
}`;
expect(result.default).toBe(expectedDefault);
});
it('should include display options with schemaType manual', () => {
const result = buildInputSchemaField();
expect(result.displayOptions).toEqual({
show: {
schemaType: ['manual'],
},
});
});
it('should merge showExtraProps when provided', () => {
const result = buildInputSchemaField({
showExtraProps: {
mode: ['advanced'],
authentication: ['oauth2'],
},
});
expect(result.displayOptions).toEqual({
show: {
mode: ['advanced'],
authentication: ['oauth2'],
schemaType: ['manual'],
},
});
});
it('should include description and hint', () => {
const result = buildInputSchemaField();
expect(result.description).toBe('Schema to use for the function');
expect(result.hint).toContain('JSON Schema');
expect(result.hint).toContain('json-schema.org');
});
it('should allow data expressions in the schema field', () => {
const result = buildInputSchemaField();
// noDataExpression is false, which means expressions are allowed
expect(result.noDataExpression).toBe(false);
// Since noDataExpression is false, this should be valid
expect(typeof result.default).toBe('string');
expect(result.noDataExpression).toBe(false);
});
it('should be a valid INodeProperties object', () => {
const result = buildInputSchemaField();
// Check all required fields for INodeProperties
expect(result).toHaveProperty('displayName');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('type');
expect(result).toHaveProperty('default');
// Verify types
expect(typeof result.displayName).toBe('string');
expect(typeof result.name).toBe('string');
expect(typeof result.type).toBe('string');
expect(typeof result.default).toBe('string');
});
it('should properly handle edge cases with showExtraProps', () => {
// Empty showExtraProps
const result1 = buildInputSchemaField({ showExtraProps: {} });
expect(result1.displayOptions).toEqual({
show: {
schemaType: ['manual'],
},
});
// showExtraProps with undefined values
const result2 = buildInputSchemaField({
showExtraProps: {
field1: undefined,
field2: ['value2'],
},
});
expect(result2.displayOptions).toEqual({
show: {
field1: undefined,
field2: ['value2'],
schemaType: ['manual'],
},
});
});
});

View File

@@ -84,7 +84,7 @@ export const buildInputSchemaField = (props?: {
}
}
}`,
noDataExpression: true,
noDataExpression: false,
typeOptions: {
rows: 10,
},

View File

@@ -0,0 +1,101 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { getOptionalOutputParser } from './N8nOutputParser';
import type { N8nStructuredOutputParser } from './N8nStructuredOutputParser';
describe('getOptionalOutputParser', () => {
let mockContext: jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
mockContext = mock<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should return undefined when hasOutputParser is false', async () => {
mockContext.getNodeParameter.mockReturnValue(false);
const result = await getOptionalOutputParser(mockContext);
expect(result).toBeUndefined();
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true);
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
});
it('should return output parser when hasOutputParser is true with default index', async () => {
const mockParser = mock<N8nStructuredOutputParser>();
mockContext.getNodeParameter.mockReturnValue(true);
mockContext.getInputConnectionData.mockResolvedValue(mockParser);
const result = await getOptionalOutputParser(mockContext);
expect(result).toBe(mockParser);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true);
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
NodeConnectionTypes.AiOutputParser,
0,
);
});
it('should use provided index when fetching output parser', async () => {
const mockParser = mock<N8nStructuredOutputParser>();
mockContext.getNodeParameter.mockReturnValue(true);
mockContext.getInputConnectionData.mockResolvedValue(mockParser);
const result = await getOptionalOutputParser(mockContext, 2);
expect(result).toBe(mockParser);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true);
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
NodeConnectionTypes.AiOutputParser,
2,
);
});
it('should handle different index values correctly', async () => {
const mockParser1 = mock<N8nStructuredOutputParser>();
const mockParser2 = mock<N8nStructuredOutputParser>();
const mockParser3 = mock<N8nStructuredOutputParser>();
mockContext.getNodeParameter.mockReturnValue(true);
mockContext.getInputConnectionData
.mockResolvedValueOnce(mockParser1)
.mockResolvedValueOnce(mockParser2)
.mockResolvedValueOnce(mockParser3);
const result1 = await getOptionalOutputParser(mockContext, 0);
const result2 = await getOptionalOutputParser(mockContext, 1);
const result3 = await getOptionalOutputParser(mockContext, 5);
expect(result1).toBe(mockParser1);
expect(result2).toBe(mockParser2);
expect(result3).toBe(mockParser3);
expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith(
1,
NodeConnectionTypes.AiOutputParser,
0,
);
expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith(
2,
NodeConnectionTypes.AiOutputParser,
1,
);
expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith(
3,
NodeConnectionTypes.AiOutputParser,
5,
);
});
it('should always check hasOutputParser at index 0', async () => {
mockContext.getNodeParameter.mockReturnValue(false);
await getOptionalOutputParser(mockContext, 3);
// Even when called with index 3, hasOutputParser is checked at index 0
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true);
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
});
});

View File

@@ -14,13 +14,14 @@ export { N8nOutputFixingParser, N8nItemListOutputParser, N8nStructuredOutputPars
export async function getOptionalOutputParser(
ctx: IExecuteFunctions,
index: number = 0,
): Promise<N8nOutputParser | undefined> {
let outputParser: N8nOutputParser | undefined;
if (ctx.getNodeParameter('hasOutputParser', 0, true) === true) {
outputParser = (await ctx.getInputConnectionData(
NodeConnectionTypes.AiOutputParser,
0,
index,
)) as N8nOutputParser;
}