fix(core): Allow .trim() in workflow-sdk parsing and coerce stringified patches (#29111)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour 2026-04-27 10:17:22 +02:00 committed by GitHub
parent d2d7dfc276
commit b44e9d1207
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 73 additions and 1 deletions

View File

@ -0,0 +1,33 @@
import { buildWorkflowInputSchema } from '../build-workflow.tool';
describe('buildWorkflowInputSchema.patches coercion', () => {
const patch = { old_str: 'foo', new_str: 'bar' };
it('accepts a native array of patches', () => {
const parsed = buildWorkflowInputSchema.parse({ patches: [patch] });
expect(parsed.patches).toEqual([patch]);
});
it('accepts a JSON-stringified array of patches', () => {
const parsed = buildWorkflowInputSchema.parse({ patches: JSON.stringify([patch]) });
expect(parsed.patches).toEqual([patch]);
});
it('rejects a non-JSON string with a helpful array-expected error', () => {
const result = buildWorkflowInputSchema.safeParse({ patches: 'not-json' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['patches']);
}
});
it('rejects a stringified object (not an array)', () => {
const result = buildWorkflowInputSchema.safeParse({ patches: JSON.stringify(patch) });
expect(result.success).toBe(false);
});
it('leaves patches undefined when not provided', () => {
const parsed = buildWorkflowInputSchema.parse({});
expect(parsed.patches).toBeUndefined();
});
});

View File

@ -15,13 +15,25 @@ const patchSchema = z.object({
new_str: z.string().describe('Replacement string'),
});
// Coerce JSON-stringified arrays into arrays. The model sometimes sends `patches`
// as a JSON string because the payload contains escaped code. Leave non-strings
// untouched so Zod can validate them normally.
function coercePatches(value: unknown): unknown {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
export const buildWorkflowInputSchema = z.object({
code: z
.string()
.optional()
.describe('Full TypeScript workflow code using @n8n/workflow-sdk. Required for new workflows.'),
patches: z
.array(patchSchema)
.preprocess(coercePatches, z.array(patchSchema))
.optional()
.describe(
'Array of {old_str, new_str} replacements to apply to existing workflow code. ' +

View File

@ -932,6 +932,32 @@ describe('AST Interpreter', () => {
});
});
describe('String.trim', () => {
let sdkFunctions: SDKFunctions;
beforeEach(() => {
sdkFunctions = createMockSDKFunctions();
});
it('should allow " abc ".trim()', () => {
const code = 'export default " abc ".trim();';
const result = interpretSDKCode(code, sdkFunctions);
expect(result).toBe('abc');
});
it('should allow trim on a template literal', () => {
const code = 'export default `\n hello\n`.trim();';
const result = interpretSDKCode(code, sdkFunctions);
expect(result).toBe('hello');
});
it('should allow trim on a variable holding a string', () => {
const code = 'const padded = " x "; export default padded.trim();';
const result = interpretSDKCode(code, sdkFunctions);
expect(result).toBe('x');
});
});
describe('expr(placeholder(...)) error', () => {
it('should throw clear error when expr receives a PlaceholderValue', () => {
const funcs: SDKFunctions = {

View File

@ -377,6 +377,7 @@ export function getSafeJSONMethod(
*/
const SAFE_STRING_METHODS: Record<string, (str: string, ...args: unknown[]) => unknown> = {
repeat: (str: string, count: unknown) => str.repeat(count as number),
trim: (str: string) => str.trim(),
};
/**