n8n/packages/@n8n/workflow-sdk/src/workflow-builder/string-utils.test.ts
Mutasem Aldmour 0feec2fea6
fix(core): Make placeholder() return string (no-changelog) (#30100)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:32:35 +00:00

282 lines
9.2 KiB
TypeScript

import { describe, it, expect } from '@jest/globals';
import {
JS_METHODS,
filterMethodsFromPath,
parseVersion,
isPlaceholderValue,
containsPlaceholderMarker,
isResourceLocatorLike,
normalizeResourceLocators,
escapeNewlinesInStringLiterals,
escapeNewlinesInExpressionStrings,
generateDeterministicNodeId,
} from './string-utils';
describe('workflow-builder/string-utils', () => {
describe('JS_METHODS', () => {
it('includes common array methods', () => {
expect(JS_METHODS.has('includes')).toBe(true);
expect(JS_METHODS.has('filter')).toBe(true);
expect(JS_METHODS.has('map')).toBe(true);
expect(JS_METHODS.has('reduce')).toBe(true);
});
it('includes common string methods', () => {
expect(JS_METHODS.has('toLowerCase')).toBe(true);
expect(JS_METHODS.has('toUpperCase')).toBe(true);
expect(JS_METHODS.has('split')).toBe(true);
expect(JS_METHODS.has('slice')).toBe(true);
});
it('includes length property', () => {
expect(JS_METHODS.has('length')).toBe(true);
});
});
describe('filterMethodsFromPath', () => {
it('removes trailing JS methods from path', () => {
expect(filterMethodsFromPath(['output', 'includes'])).toEqual(['output']);
});
it('removes multiple trailing methods', () => {
expect(filterMethodsFromPath(['output', 'toLowerCase', 'includes'])).toEqual(['output']);
});
it('preserves non-method path segments', () => {
expect(filterMethodsFromPath(['data', 'items', 'value'])).toEqual(['data', 'items', 'value']);
});
it('returns empty array if all are methods', () => {
expect(filterMethodsFromPath(['includes', 'map'])).toEqual([]);
});
it('handles empty array', () => {
expect(filterMethodsFromPath([])).toEqual([]);
});
});
describe('parseVersion', () => {
it('returns 1 for undefined version', () => {
expect(parseVersion(undefined)).toBe(1);
});
it('returns 1 for empty string', () => {
expect(parseVersion('')).toBe(1);
});
it('parses version with v prefix', () => {
expect(parseVersion('v2')).toBe(2);
});
it('parses version without v prefix', () => {
expect(parseVersion('3')).toBe(3);
});
it('parses decimal version', () => {
expect(parseVersion('1.5')).toBe(1.5);
});
it('parses version with v prefix and decimal', () => {
expect(parseVersion('v2.1')).toBe(2.1);
});
it('returns number directly when given a number', () => {
expect(parseVersion(1.3)).toBe(1.3);
expect(parseVersion(2)).toBe(2);
});
});
describe('isPlaceholderValue', () => {
it('returns true for placeholder string', () => {
expect(isPlaceholderValue('<__PLACEHOLDER_VALUE__test__>')).toBe(true);
});
it('returns false for regular string', () => {
expect(isPlaceholderValue('regular string')).toBe(false);
});
it('returns false for non-string values', () => {
expect(isPlaceholderValue(123)).toBe(false);
expect(isPlaceholderValue(null)).toBe(false);
expect(isPlaceholderValue(undefined)).toBe(false);
});
it('returns false for string that only starts with prefix', () => {
expect(isPlaceholderValue('<__PLACEHOLDER_VALUE__')).toBe(false);
});
});
describe('containsPlaceholderMarker', () => {
it('returns true for bare placeholder string', () => {
expect(containsPlaceholderMarker('<__PLACEHOLDER_VALUE__test__>')).toBe(true);
});
it('returns true for expr()-wrapped placeholder (leading "=")', () => {
expect(containsPlaceholderMarker('=<__PLACEHOLDER_VALUE__test__>')).toBe(true);
});
it('returns true for placeholder embedded in an expression block', () => {
expect(containsPlaceholderMarker('={{ "prefix-" + "<__PLACEHOLDER_VALUE__name__>" }}')).toBe(
true,
);
});
it('returns false for literal strings without the marker', () => {
expect(containsPlaceholderMarker('regular string')).toBe(false);
expect(containsPlaceholderMarker('=expression')).toBe(false);
});
it('returns false for incomplete marker (missing closing)', () => {
expect(containsPlaceholderMarker('<__PLACEHOLDER_VALUE__test')).toBe(false);
});
it('returns false for non-string values', () => {
expect(containsPlaceholderMarker(123)).toBe(false);
expect(containsPlaceholderMarker(null)).toBe(false);
expect(containsPlaceholderMarker(undefined)).toBe(false);
expect(containsPlaceholderMarker({})).toBe(false);
});
});
describe('isResourceLocatorLike', () => {
it('returns true for object with mode and value', () => {
expect(isResourceLocatorLike({ mode: 'list', value: 'test' })).toBe(true);
});
it('returns false for object without mode', () => {
expect(isResourceLocatorLike({ value: 'test' })).toBe(false);
});
it('returns false for object without value', () => {
expect(isResourceLocatorLike({ mode: 'list' })).toBe(false);
});
it('returns false for null', () => {
expect(isResourceLocatorLike(null)).toBe(false);
});
it('returns false for array', () => {
expect(isResourceLocatorLike([{ mode: 'list', value: 'test' }])).toBe(false);
});
it('returns false for primitive', () => {
expect(isResourceLocatorLike('string')).toBe(false);
expect(isResourceLocatorLike(123)).toBe(false);
});
});
describe('normalizeResourceLocators', () => {
it('adds __rl: true to resource locator objects', () => {
const params = { field: { mode: 'list', value: 'test' } };
const result = normalizeResourceLocators(params);
expect(result).toEqual({ field: { __rl: true, mode: 'list', value: 'test' } });
});
it('clears placeholder value when mode is list', () => {
const params = { field: { mode: 'list', value: '<__PLACEHOLDER_VALUE__test__>' } };
const result = normalizeResourceLocators(params) as Record<string, Record<string, unknown>>;
expect(result.field.value).toBe('');
});
it('handles nested objects', () => {
const params = { outer: { inner: { mode: 'id', value: '123' } } };
const result = normalizeResourceLocators(params) as Record<
string,
Record<string, Record<string, unknown>>
>;
expect(result.outer.inner.__rl).toBe(true);
});
it('handles arrays with nested resource locators', () => {
// Resource locators in arrays need to be in object properties to get tagged
const params = [{ field: { mode: 'url', value: 'http://test.com' } }];
const result = normalizeResourceLocators(params) as Array<
Record<string, Record<string, unknown>>
>;
expect(result[0].field.__rl).toBe(true);
});
it('returns primitive unchanged', () => {
expect(normalizeResourceLocators('test')).toBe('test');
expect(normalizeResourceLocators(123)).toBe(123);
expect(normalizeResourceLocators(null)).toBe(null);
});
});
describe('escapeNewlinesInStringLiterals', () => {
it('escapes newlines in double-quoted strings', () => {
const code = '"line1\nline2"';
expect(escapeNewlinesInStringLiterals(code)).toBe('"line1\\nline2"');
});
it('escapes newlines in single-quoted strings', () => {
const code = "'line1\nline2'";
expect(escapeNewlinesInStringLiterals(code)).toBe("'line1\\nline2'");
});
it('preserves template literals unchanged', () => {
const code = '`line1\nline2`';
expect(escapeNewlinesInStringLiterals(code)).toBe('`line1\nline2`');
});
it('does not double-escape already escaped newlines', () => {
const code = '"already\\nescaped"';
expect(escapeNewlinesInStringLiterals(code)).toBe('"already\\nescaped"');
});
it('preserves regex literals', () => {
const code = 'const re = /test/g';
expect(escapeNewlinesInStringLiterals(code)).toBe('const re = /test/g');
});
});
describe('escapeNewlinesInExpressionStrings', () => {
it('escapes newlines in {{ }} blocks', () => {
const value = '={{ "line1\nline2" }}';
expect(escapeNewlinesInExpressionStrings(value)).toBe('={{ "line1\\nline2" }}');
});
it('only processes strings starting with =', () => {
const value = '{{ "no equals" }}';
expect(escapeNewlinesInExpressionStrings(value)).toBe('{{ "no equals" }}');
});
it('handles nested objects recursively', () => {
const value = { param: '={{ "test\nvalue" }}' };
const result = escapeNewlinesInExpressionStrings(value) as Record<string, string>;
expect(result.param).toBe('={{ "test\\nvalue" }}');
});
it('handles arrays recursively', () => {
const value = ['={{ "test\nvalue" }}'];
const result = escapeNewlinesInExpressionStrings(value) as string[];
expect(result[0]).toBe('={{ "test\\nvalue" }}');
});
it('returns non-string primitives unchanged', () => {
expect(escapeNewlinesInExpressionStrings(123)).toBe(123);
expect(escapeNewlinesInExpressionStrings(null)).toBe(null);
});
});
describe('generateDeterministicNodeId', () => {
it('generates UUID format', () => {
const id = generateDeterministicNodeId('wf1', 'type', 'name');
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it('is deterministic for same inputs', () => {
const id1 = generateDeterministicNodeId('wf1', 'type', 'name');
const id2 = generateDeterministicNodeId('wf1', 'type', 'name');
expect(id1).toBe(id2);
});
it('produces different IDs for different inputs', () => {
const id1 = generateDeterministicNodeId('wf1', 'type', 'name1');
const id2 = generateDeterministicNodeId('wf1', 'type', 'name2');
expect(id1).not.toBe(id2);
});
});
});