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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,274 @@
// @vitest-environment jsdom
import { evaluate } from './helpers';
import { arrayExtensions } from '../../src/extensions/array-extensions';
describe('Data Transformation Functions', () => {
describe('Array Data Transformation Functions', () => {
test('.randomItem() should work correctly on an array', () => {
expect(evaluate('={{ [1,2,3].randomItem() }}')).not.toBeUndefined();
});
test('.isNotEmpty() should work correctly on an array', () => {
expect(evaluate('={{ [1,2,3, "imhere"].isNotEmpty() }}')).toEqual(true);
});
test('.pluck() should work correctly on an array', () => {
expect(
evaluate(`={{ [
{ value: 1, string: '1' },
{ value: 2, string: '2' },
{ value: 3, string: '3' },
{ value: 4, string: '4' },
{ value: 5, string: '5' },
{ value: 6, string: '6' },
{ value: { something: 'else' } }
].pluck("value") }}`),
).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6, { something: 'else' }]));
});
test('.pluck() should work correctly for multiple values', () => {
expect(
evaluate(`={{ [
{
firstName: 'John',
lastName: 'Doe',
phone: {
home: '111-222',
office: '333-444'
}
},
{
firstName: 'Jane',
lastName: 'Doe',
phone: {
office: '555-666'
}
}
].pluck("firstName", "lastName") }}`),
).toEqual(
expect.arrayContaining([
['John', 'Doe'],
['Jane', 'Doe'],
]),
);
});
test('.pluck() should work return everything with no args', () => {
expect(
evaluate(`={{ [
{ value: 1, string: '1' },
{ value: 2, string: '2' },
{ value: 3, string: '3' },
{ value: 4, string: '4' },
{ value: 5, string: '5' },
{ value: 6, string: '6' },
{ value: { something: 'else' } }
].pluck() }}`),
).toEqual(
expect.arrayContaining([
{ value: 1, string: '1' },
{ value: 2, string: '2' },
{ value: 3, string: '3' },
{ value: 4, string: '4' },
{ value: 5, string: '5' },
{ value: 6, string: '6' },
{ value: { something: 'else' } },
]),
);
});
test('.unique() should work correctly on an array', () => {
expect(evaluate('={{ ["repeat","repeat","a","b","c"].unique() }}')).toEqual(
expect.arrayContaining(['repeat', 'repeat', 'a', 'b', 'c']),
);
});
test('.unique() should work on an arrays containing nulls, objects and arrays', () => {
expect(
evaluate('={{ [1, 2, 3, "as", {}, {}, 1, 2, [1,2], "[sad]", "[sad]", null].unique() }}'),
).toEqual([1, 2, 3, 'as', {}, [1, 2], '[sad]', null]);
});
test('.unique() should work on an arrays of objects', () => {
expect(
evaluate(
"={{ [{'name':'Nathan', age:42}, {'name':'Jan', age:16}, {'name':'Nathan', age:21}].unique('name') }}",
),
).toEqual([
{ name: 'Nathan', age: 42 },
{ name: 'Jan', age: 16 },
]);
});
test('.isEmpty() should work correctly on an array', () => {
expect(evaluate('={{ [].isEmpty() }}')).toEqual(true);
});
test('.isEmpty() should work correctly on an array', () => {
expect(evaluate('={{ [1].isEmpty() }}')).toEqual(false);
});
test('.last() should work correctly on an array', () => {
expect(evaluate('={{ ["repeat","repeat","a","b","c"].last() }}')).toEqual('c');
});
test('.first() should work correctly on an array', () => {
expect(evaluate('={{ ["repeat","repeat","a","b","c"].first() }}')).toEqual('repeat');
});
test('.merge() should work correctly on an array', () => {
expect(
evaluate(
'={{ [{ test1: 1, test2: 2 }, { test1: 1, test3: 3 }].merge([{ test1: 2, test3: 3 }, { test4: 4 }]) }}',
),
).toEqual({ test1: 1, test2: 2, test3: 3, test4: 4 });
});
test('.merge() should work correctly without arguments', () => {
expect(
evaluate(
'={{ [{ a: 1, some: null }, { a: 2, c: "something" }, 2, "asds", { b: 23 }, null, [1, 2]].merge() }}',
),
).toEqual({ a: 1, some: null, c: 'something', b: 23 });
});
test('.smartJoin() should work correctly on an array of objects', () => {
expect(
evaluate(
'={{ [{ name: "test1", value: "value1" }, { name: "test2", value: null }].smartJoin("name", "value") }}',
),
).toEqual({
test1: 'value1',
test2: null,
});
});
test('.renameKeys() should work correctly on an array of objects', () => {
expect(
evaluate(
'={{ [{ test1: 1, test2: 2 }, { test1: 1, test3: 3 }].renameKeys("test1", "rename1", "test3", "rename3") }}',
),
).toEqual([
{ rename1: 1, test2: 2 },
{ rename1: 1, rename3: 3 },
]);
});
test('.sum() should work on an array of numbers', () => {
expect(evaluate('={{ [1, 2, 3, 4, 5, 6].sum() }}')).toEqual(21);
expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].sum() }}')).toThrow();
});
test('.average() should work on an array of numbers', () => {
expect(evaluate('={{ [1, 2, 3, 4, 5, 6].average() }}')).toEqual(3.5);
expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].average() }}')).toThrow();
});
test('.min() should work on an array of numbers', () => {
expect(evaluate('={{ [1, 2, 3, 4, 5, 6].min() }}')).toEqual(1);
expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].min() }}')).toThrow();
});
test('.max() should work on an array of numbers', () => {
expect(evaluate('={{ [1, 2, 3, 4, 5, 6].max() }}')).toEqual(6);
expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].max() }}')).toThrow();
});
test('.union() should work on an array of objects', () => {
expect(
evaluate(
'={{ [{ test1: 1 }, { test2: 2 }].union([{ test1: 1, test3: 3 }, { test2: 2 }, { test4: 4 }]) }}',
),
).toEqual([{ test1: 1 }, { test2: 2 }, { test1: 1, test3: 3 }, { test4: 4 }]);
});
test('.union() should work on an arrays containing nulls, objects and arrays', () => {
expect(evaluate('={{ [1, 2, "dd", {}, null].union([1, {}, null, 3]) }}')).toEqual([
1,
2,
'dd',
{},
null,
3,
]);
});
test('.intersection() should work on an array of objects', () => {
expect(
evaluate(
'={{ [{ test1: 1 }, { test2: 2 }].intersection([{ test1: 1, test3: 3 }, { test2: 2 }, { test4: 4 }]) }}',
),
).toEqual([{ test2: 2 }]);
});
test('.intersection() should work on an arrays containing nulls, objects and arrays', () => {
expect(evaluate('={{ [1, 2, "dd", {}, null].intersection([1, {}, null]) }}')).toEqual([
1,
{},
null,
]);
});
test('.difference() should work on an array of objects', () => {
expect(
evaluate(
'={{ [{ test1: 1 }, { test2: 2 }].difference([{ test1: 1, test3: 3 }, { test2: 2 }, { test4: 4 }]) }}',
),
).toEqual([{ test1: 1 }]);
expect(
evaluate('={{ [{ test1: 1 }, { test2: 2 }].difference([{ test1: 1 }, { test2: 2 }]) }}'),
).toEqual([]);
});
test('.difference() should work on an arrays containing nulls, objects and arrays', () => {
expect(
evaluate('={{ [1, 2, "dd", {}, null, ["a", 1]].difference([1, {}, null, ["a", 1]]) }}'),
).toEqual([2, 'dd']);
});
test('.compact() should work on an array', () => {
expect(
evaluate(
'={{ [{ test1: 1, test2: undefined, test3: null }, null, undefined, 1, 2, 0, { test: "asdf" }].compact() }}',
),
).toEqual([{ test1: 1 }, 1, 2, 0, { test: 'asdf' }]);
});
test('.chunk() should work on an array', () => {
expect(evaluate('={{ numberList(1, 20).chunk(5) }}')).toEqual([
[1, 2, 3, 4, 5],
[6, 7, 8, 9, 10],
[11, 12, 13, 14, 15],
[16, 17, 18, 19, 20],
]);
});
test('.toJsonString() should work on an array', () => {
expect(evaluate('={{ [true, 1, "one", {foo: "bar"}].toJsonString() }}')).toEqual(
'[true,1,"one",{"foo":"bar"}]',
);
});
test('.append() should work on an array', () => {
expect(evaluate('={{ [1,2,3].append(4,5,"done") }}')).toEqual([1, 2, 3, 4, 5, 'done']);
});
describe('Conversion methods', () => {
test('should exist but return undefined (to not break expressions with mixed data)', () => {
expect(evaluate('={{ numberList(1, 20).toInt() }}')).toBeUndefined();
expect(evaluate('={{ numberList(1, 20).toFloat() }}')).toBeUndefined();
expect(evaluate('={{ numberList(1, 20).toBoolean() }}')).toBeUndefined();
expect(evaluate('={{ numberList(1, 20).toDateTime() }}')).toBeUndefined();
});
test('should not have a doc (hidden from autocomplete)', () => {
expect(arrayExtensions.functions.toInt.doc).toBeUndefined();
expect(arrayExtensions.functions.toFloat.doc).toBeUndefined();
expect(arrayExtensions.functions.toBoolean.doc).toBeUndefined();
expect(arrayExtensions.functions.toDateTime.doc).toBeUndefined();
});
});
});
});
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { evaluate } from './helpers';
import { booleanExtensions } from '../../src/extensions/boolean-extensions';
describe('Data Transformation Functions', () => {
describe('Boolean Data Transformation Functions', () => {
describe('Conversion methods', () => {
describe('toInt/toFloat', () => {
test('should return 1 for true, 0 for false', () => {
expect(evaluate('={{ (true).toInt() }}')).toEqual(1);
expect(evaluate('={{ (true).toFloat() }}')).toEqual(1);
expect(evaluate('={{ (false).toInt() }}')).toEqual(0);
expect(evaluate('={{ (false).toFloat() }}')).toEqual(0);
});
});
describe('toDateTime', () => {
test('should return undefined', () => {
expect(evaluate('={{ (true).toDateTime() }}')).toBeUndefined();
});
});
describe('toBoolean', () => {
test('should return itself', () => {
expect(evaluate('={{ (true).toDateTime() }}')).toBeUndefined();
});
});
test('should not have a doc (hidden from autocomplete)', () => {
expect(booleanExtensions.functions.toFloat.doc).toBeUndefined();
expect(booleanExtensions.functions.toBoolean.doc).toBeUndefined();
expect(booleanExtensions.functions.toDateTime.doc).toBeUndefined();
});
});
});
});
@@ -0,0 +1,379 @@
// @vitest-environment jsdom
import { DateTime } from 'luxon';
import { evaluate, getLocalISOString } from './helpers';
import { dateExtensions } from '../../src/extensions/date-extensions';
import { getGlobalState } from '../../src/global-state';
const { defaultTimezone } = getGlobalState();
describe('Data Transformation Functions', () => {
describe('Date Data Transformation Functions', () => {
test('.isWeekend() should work correctly on a date', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 20).isWeekend() }}')).toBe(false);
expect(evaluate('={{ DateTime.local(2023, 1, 21).isWeekend() }}')).toBe(true);
expect(evaluate('={{ DateTime.local(2023, 1, 22).isWeekend() }}')).toBe(true);
expect(evaluate('={{ DateTime.local(2023, 1, 23).isWeekend() }}')).toBe(false);
});
describe('.beginningOf', () => {
test('.beginningOf("week") should work correctly on a date', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 20).beginningOf("week") }}')).toEqual(
DateTime.local(2023, 1, 16, { zone: defaultTimezone }),
);
expect(evaluate('={{ new Date(2023, 0, 20).beginningOf("week") }}')).toEqual(
DateTime.local(2023, 1, 16, { zone: defaultTimezone }).toJSDate(),
);
});
test('.beginningOf("week") should work correctly on a string', () => {
const evaluatedDate = evaluate('={{ "2023-01-30".toDate().beginningOf("week") }}');
const expectedDate = DateTime.local(2023, 1, 23, { zone: defaultTimezone }).toJSDate();
if (evaluatedDate && evaluatedDate instanceof Date) {
expect(evaluatedDate.toDateString()).toEqual(expectedDate.toDateString());
}
});
test('.beginningOf("month") should work correctly on a string', () => {
const evaluatedDate = evaluate('={{ "2023-06-16".toDate().beginningOf("month") }}');
const expectedDate = DateTime.local(2023, 6, 1, { zone: defaultTimezone }).toJSDate();
if (evaluatedDate && evaluatedDate instanceof Date) {
expect(evaluatedDate.toDateString()).toEqual(expectedDate.toDateString());
}
});
test('.beginningOf("year") should work correctly on a string', () => {
const evaluatedDate = evaluate('={{ "2023-01-30".toDate().beginningOf("year") }}');
const expectedDate = DateTime.local(2023, 1, 1, { zone: defaultTimezone }).toJSDate();
if (evaluatedDate && evaluatedDate instanceof Date) {
expect(evaluatedDate.toDateString()).toEqual(expectedDate.toDateString());
}
});
});
test('.endOfMonth() should work correctly on a date', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 16).endOfMonth() }}')).toEqual(
DateTime.local(2023, 1, 31, 23, 59, 59, 999, { zone: defaultTimezone }),
);
expect(evaluate('={{ new Date(2023, 0, 16).endOfMonth() }}')).toEqual(
DateTime.local(2023, 1, 31, 23, 59, 59, 999, { zone: defaultTimezone }).toJSDate(),
);
});
describe('.extract', () => {
test('.extract("day") should work correctly on a date', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 20).extract("day") }}')).toEqual(20);
});
test('should extract year from a date', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49").extract("year") }}')).toEqual(2024);
});
test('should extract yearDayNumber from a date', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49").extract("yearDayNumber") }}')).toEqual(
90,
);
});
test('should extract month from a date', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49").extract("month") }}')).toEqual(3);
});
test('should extract week from a date', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 20).extract() }}')).toEqual(3);
expect(evaluate('={{ DateTime.local(2023, 1, 20).extract("week") }}')).toEqual(3);
});
test('should extract day from a date', () => {
expect(evaluate('={{ DateTime.fromISO("2024-03-30T18:49").extract("day") }}')).toEqual(30);
});
test('should extract hour from a date', () => {
expect(evaluate('={{ DateTime.fromISO("2024-03-30T18:49").extract("hour") }}')).toEqual(18);
});
test('should extract minute from a date', () => {
expect(evaluate('={{ DateTime.fromISO("2024-03-30T18:49").extract("minute") }}')).toEqual(
49,
);
});
test('should extract second from a date', () => {
expect(evaluate('={{ DateTime.fromISO("2024-03-30T18:49").extract("second") }}')).toEqual(
0,
);
});
test('should extract millisecond from a date', () => {
expect(
evaluate('={{ DateTime.fromISO("2024-03-30T18:49:00.123Z").extract("millisecond") }}'),
).toEqual(123);
});
test('should return undefined for invalid unit', () => {
expect(evaluate('={{ DateTime.fromISO("2024-03-30T18:49").extract("invalid") }}')).toBe(
null,
);
});
});
describe('.format', () => {
test('should format date with custom format', () => {
expect(
evaluate('={{ DateTime.fromISO("2024-03-30T18:49").format("yyyy LLL dd") }}'),
).toEqual('2024 Mar 30');
});
test('should format date with ISO format', () => {
expect(
evaluate('={{ DateTime.fromISO("2024-03-30T18:49").format("yyyy-MM-dd\'T\'HH:mm:ss") }}'),
).toEqual('2024-03-30T18:49:00');
});
});
test('.toDate() should work on a string', () => {
const date = new Date(2022, 0, 3);
expect(evaluate(`={{ "${getLocalISOString(date)}".toDate() }}`)).toEqual(date);
});
describe('.inBetween', () => {
test('should work on string and Date', () => {
expect(
evaluate("={{ $now.isBetween('2023-06-23'.toDate(), '2023-06-23') }}"),
).toBeDefined();
});
test('should work on string and DateTime', () => {
expect(evaluate("={{ $now.isBetween($now, '2023-06-23') }}")).toBeDefined();
});
test('should not work for invalid strings', () => {
expect(evaluate("={{ $now.isBetween($now, 'invalid') }}")).toBeUndefined();
});
test('should not work for numbers', () => {
expect(evaluate('={{ $now.isBetween($now, 1) }}')).toBeUndefined();
});
test('should not work for a single argument', () => {
expect(() => evaluate('={{ $now.isBetween($now) }}')).toThrow();
});
test('should not work for a more than two arguments', () => {
expect(() =>
evaluate("={{ $now.isBetween($now, '2023-06-23', '2023-09-21'.toDate()) }}"),
).toThrow();
});
});
describe('.diffTo', () => {
test('should work with a single unit', () => {
expect(
evaluate(
"={{ '2025-01-01'.toDateTime().diffTo('2024-03-30T18:49:07.234', 'days').floor() }}",
),
).toEqual(276);
});
test('should work with an array of units', () => {
expect(
evaluate(
"={{ '2025-01-01T00:00:00.000'.toDateTime().diffTo('2024-03-30T18:49:07.234', ['months', 'days']) }}",
),
).toEqual({ months: 9, days: 1.2158884953703704 });
});
test('should return difference in days', () => {
expect(
evaluate(
'={{ DateTime.fromISO("2024-03-30T18:49:00Z").diffTo("2024-03-25T18:49:00Z", "days") }}',
),
).toEqual(5);
});
test('should return difference in hours', () => {
expect(
evaluate(
'={{ DateTime.fromISO("2024-03-30T18:49:00Z").diffTo("2024-03-30T12:49:00Z", "hours") }}',
),
).toEqual(6);
});
test('should return difference in minutes', () => {
expect(
evaluate(
'={{ DateTime.fromISO("2024-03-30T18:49:00Z").diffTo("2024-03-30T18:44:00Z", "minutes") }}',
),
).toEqual(5);
});
test('should return difference in seconds', () => {
expect(
evaluate(
'={{ DateTime.fromISO("2024-03-30T18:49:00Z").diffTo("2024-03-30T18:48:55Z", "seconds") }}',
),
).toEqual(5);
});
test('should return difference in milliseconds', () => {
expect(
evaluate(
'={{ DateTime.fromISO("2024-03-30T18:49:00.500Z").diffTo("2024-03-30T18:49:00.000Z", "milliseconds") }}',
),
).toEqual(500);
});
test('should throw for invalid unit', () => {
expect(() =>
evaluate(
'={{ DateTime.fromISO("2024-03-30T18:49:00Z").diffTo("2024-03-30T18:49:00Z", "invalid") }}',
),
).toThrow('Unsupported unit');
});
});
describe('.toDateTime', () => {
test('should return itself for DateTime', () => {
const result = evaluate(
"={{ DateTime.fromFormat('01-01-2024', 'dd-MM-yyyy').toDateTime() }}",
) as unknown as DateTime;
expect(result).toBeInstanceOf(DateTime);
expect(result.day).toEqual(1);
expect(result.month).toEqual(1);
expect(result.year).toEqual(2024);
});
test('should return a DateTime for JS Date', () => {
const result = evaluate(
'={{ new Date(2024, 0, 1, 12).toDateTime() }}',
) as unknown as DateTime;
expect(result).toBeInstanceOf(DateTime);
expect(result.day).toEqual(1);
expect(result.month).toEqual(1);
expect(result.year).toEqual(2024);
});
});
describe('.toInt/.toFloat', () => {
test('should return milliseconds for DateTime', () => {
expect(evaluate("={{ DateTime.fromISO('2024-01-01T00:00:00.000Z').toInt() }}")).toEqual(
1704067200000,
);
});
test('should return milliseconds for JS Date', () => {
expect(evaluate('={{ new Date("2024-01-01T00:00:00.000Z").toFloat() }}')).toEqual(
1704067200000,
);
});
test('should not have a doc (hidden from autocomplete)', () => {
expect(dateExtensions.functions.toInt.doc).toBeUndefined();
expect(dateExtensions.functions.toFloat.doc).toBeUndefined();
});
});
describe('.toBoolean', () => {
test('should return undefined', () => {
expect(evaluate('={{ new Date("2024-01-01T00:00:00.000Z").toBoolean() }}')).toBeUndefined();
});
test('should not have a doc (hidden from autocomplete)', () => {
expect(dateExtensions.functions.toBoolean.doc).toBeUndefined();
});
});
describe('.isInLast', () => {
it('should return true if the date is within the last n minutes', () => {
expect(
evaluate(
`={{ new Date("${DateTime.now().minus({ minutes: 5 }).toISO()}").isInLast(10, "minutes") }}`,
),
).toBe(true);
});
it('should return false if the date is not within the last n minutes', () => {
expect(
evaluate(
`={{ new Date("${DateTime.now().minus({ minutes: 15 }).toISO()}").isInLast(10, "minutes") }}`,
),
).toBe(false);
});
it('should handle default unit as minutes', () => {
expect(
evaluate(
`={{ new Date("${DateTime.now().minus({ minutes: 5 }).toISO()}").isInLast(10) }}`,
),
).toBe(true);
});
});
describe('.minus', () => {
it('should subtract days from the date', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49:00Z").minus(7, "days") }}')).toEqual(
new Date('2024-03-23T18:49:00.000Z'),
);
});
it('should subtract years from the date', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49:00Z").minus(4, "years") }}')).toEqual(
new Date('2020-03-30T18:49:00.000Z'),
);
});
it('should handle default unit as milliseconds', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49:00Z").minus(1000) }}')).toEqual(
new Date('2024-03-30T18:48:59.000Z'),
);
});
it('should handle DateTime instances', () => {
expect(
evaluate('={{ DateTime.fromISO("2024-03-30T18:49:00Z").minus(1, "day").toJSDate() }}'),
).toEqual(new Date('2024-03-29T18:49:00.000Z'));
});
});
describe('.plus', () => {
it('should subtract days from the date', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49:00Z").plus(7, "days") }}')).toEqual(
new Date('2024-04-06T18:49:00.000Z'),
);
});
it('should subtract years from the date', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49:00Z").plus(4, "years") }}')).toEqual(
new Date('2028-03-30T18:49:00.000Z'),
);
});
it('should handle default unit as milliseconds', () => {
expect(evaluate('={{ new Date("2024-03-30T18:49:00Z").plus(1000) }}')).toEqual(
new Date('2024-03-30T18:49:01.000Z'),
);
});
it('should handle DateTime instances', () => {
expect(
evaluate('={{ DateTime.fromISO("2024-03-30T18:49:00Z").plus(1, "day").toJSDate() }}'),
).toEqual(new Date('2024-03-31T18:49:00.000Z'));
});
});
describe('.isDst', () => {
test('should return true for a date in DST', () => {
expect(evaluate('={{ DateTime.fromISO("2024-06-30T18:49:00Z").isDst() }}')).toBe(true);
});
test('should return false for a date not in DST', () => {
expect(evaluate('={{ DateTime.fromISO("2024-01-30T18:49:00Z").isDst() }}')).toBe(false);
});
});
});
});
@@ -0,0 +1,266 @@
// @vitest-environment jsdom
/* eslint-disable n8n-local-rules/no-interpolation-in-regular-string */
import { evaluate } from './helpers';
import { ExpressionExtensionError } from '../../src/errors/expression-extension.error';
import { extendTransform, extend } from '../../src/extensions';
import { joinExpression, splitExpression } from '../../src/extensions/expression-parser';
describe('Expression Extension Transforms', () => {
describe('extend() transform', () => {
test('Basic transform with .isEmpty', () => {
expect(extendTransform('"".isEmpty()')!.code).toEqual('extend("", "isEmpty", [])');
});
test('Chained transform with .toSnakeCase.toSentenceCase', () => {
expect(extendTransform('"".toSnakeCase().toSentenceCase(2)')!.code).toEqual(
'extend(extend("", "toSnakeCase", []), "toSentenceCase", [2])',
);
});
test('Chained transform with native functions .toSnakeCase.trim.toSentenceCase', () => {
expect(extendTransform('"aaa ".toSnakeCase().trim().toSentenceCase(2)')!.code).toEqual(
'extend(extend("aaa ", "toSnakeCase", []).trim(), "toSentenceCase", [2])',
);
});
});
});
describe('Expression Parser', () => {
describe('Compatible splitting', () => {
test('Lone expression', () => {
expect(splitExpression('{{ "" }}')).toEqual([
{ type: 'text', text: '' },
{ type: 'code', text: ' "" ', hasClosingBrackets: true },
]);
});
test('Multiple expression', () => {
expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual(
[
{ type: 'text', text: '' },
{ type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true },
{ type: 'text', text: ' you have $' },
{ type: 'code', text: ' (100).format() ', hasClosingBrackets: true },
{ type: 'text', text: '.' },
],
);
});
test('Unclosed expression', () => {
expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')).toEqual([
{ type: 'text', text: '' },
{ type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true },
{ type: 'text', text: ' you have $' },
{ type: 'code', text: ' (100).format()', hasClosingBrackets: false },
]);
});
test('Escaped opening bracket', () => {
expect(splitExpression('test \\{{ no code }}')).toEqual([
{ type: 'text', text: 'test \\{{ no code }}' },
]);
});
test('Escaped closinging bracket', () => {
expect(splitExpression('test {{ code.test("\\}}") }}')).toEqual([
{ type: 'text', text: 'test ' },
{ type: 'code', text: ' code.test("}}") ', hasClosingBrackets: true },
]);
});
});
describe('Compatible joining', () => {
test('Lone expression', () => {
expect(joinExpression(splitExpression('{{ "" }}'))).toEqual('{{ "" }}');
});
test('Multiple expression', () => {
expect(
joinExpression(
splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.'),
),
).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.');
});
test('Unclosed expression', () => {
expect(
joinExpression(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')),
).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format()');
});
test('Escaped opening bracket', () => {
expect(joinExpression(splitExpression('test \\{{ no code }}'))).toEqual(
'test \\{{ no code }}',
);
});
test('Escaped closing bracket', () => {
expect(joinExpression(splitExpression('test {{ code.test("\\}}") }}'))).toEqual(
'test {{ code.test("\\}}") }}',
);
});
});
describe('Edge cases', () => {
test("Nested member access with name of function inside a function doesn't result in function call", () => {
expect(evaluate('={{ Math.floor([1, 2, 3, 4].length + 10) }}')).toEqual(14);
expect(extendTransform('Math.floor([1, 2, 3, 4].length + 10)')?.code).toBe(
'extend(Math, "floor", [[1, 2, 3, 4].length + 10])',
);
});
});
describe('Test newer ES syntax', () => {
test('Optional chaining transforms', () => {
expect(extendTransform('$json.something?.test.funcCall()')?.code).toBe(
'window.chainCancelToken1 = ((window.chainValue1 = $json.something) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1.test.funcCall();',
);
expect(extendTransform('$json.something?.test.funcCall()?.somethingElse')?.code).toBe(
'window.chainCancelToken1 = ((window.chainValue1 = $json.something) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainCancelToken1 = ((window.chainValue1 = window.chainValue1.test.funcCall()) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1.somethingElse;',
);
expect(extendTransform('$json.something?.test.funcCall().somethingElse')?.code).toBe(
'window.chainCancelToken1 = ((window.chainValue1 = $json.something) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1.test.funcCall().somethingElse;',
);
expect(
extendTransform('$json.something?.test.funcCall()?.somethingElse.otherCall()')?.code,
).toBe(
'window.chainCancelToken1 = ((window.chainValue1 = $json.something) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainCancelToken1 = ((window.chainValue1 = window.chainValue1.test.funcCall()) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1.somethingElse.otherCall();',
);
expect(evaluate('={{ [1, 2, 3, 4]?.sum() }}')).toBe(10);
});
test('Optional chaining transforms on calls', () => {
expect(extendTransform('Math.min?.(1)')?.code).toBe(
'window.chainCancelToken1 = ((window.chainValue1 = extendOptional(Math, "min")) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1(1);',
);
expect(extendTransform('Math?.min?.(1)')?.code).toBe(
'window.chainCancelToken1 = ((window.chainValue1 = Math) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainCancelToken1 = ((window.chainValue1 = extendOptional(window.chainValue1, "min")) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1(1);',
);
expect(extendTransform('$json.test.test2?.sum()')?.code).toBe(
'window.chainCancelToken1 = ((window.chainValue1 = $json.test.test2) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : extend(window.chainValue1, "sum", []);',
);
expect(extendTransform('$json.test.test2?.sum?.()')?.code).toBe(
'window.chainCancelToken1 = ((window.chainValue1 = $json.test.test2) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainCancelToken1 = ((window.chainValue1 = extendOptional(window.chainValue1, "sum")) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1();',
);
expect(evaluate('={{ [1, 2, 3, 4].sum?.() }}')).toBe(10);
});
test('Multiple optional chains in an expression', () => {
expect(extendTransform('$json.test?.test2($json.test?.test2)')?.code).toBe(`window.chainCancelToken2 = ((window.chainValue2 = $json.test) ?? undefined) === undefined, window.chainCancelToken2 === true ? undefined : window.chainValue2.test2(
(window.chainCancelToken1 = ((window.chainValue1 = $json.test) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1.test2)
);`);
expect(extendTransform('$json.test?.test2($json.test.sum?.())')?.code).toBe(`window.chainCancelToken2 = ((window.chainValue2 = $json.test) ?? undefined) === undefined, window.chainCancelToken2 === true ? undefined : window.chainValue2.test2(
(window.chainCancelToken1 = ((window.chainValue1 = extendOptional($json.test, "sum")) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1())
);`);
});
expect(evaluate('={{ [1, 2, 3, 4]?.sum((undefined)?.test) }}')).toBe(10);
});
describe('Non dot extensions', () => {
test('min', () => {
expect(evaluate('={{ min(1, 2, 3, 4, 5, 6) }}')).toEqual(1);
expect(evaluate('={{ min(1, NaN, 3, 4, 5, 6) }}')).toBeNaN();
});
test('max', () => {
expect(evaluate('={{ max(1, 2, 3, 4, 5, 6) }}')).toEqual(6);
expect(evaluate('={{ max(1, NaN, 3, 4, 5, 6) }}')).toBeNaN();
});
test('average', () => {
expect(evaluate('={{ average(1, 2, 3, 4, 5, 6) }}')).toEqual(3.5);
expect(evaluate('={{ average(1, NaN, 3, 4, 5, 6) }}')).toBeNaN();
});
test('numberList', () => {
expect(evaluate('={{ numberList(1, 10) }}')).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect(evaluate('={{ numberList(1, -10) }}')).toEqual([
1, 0, -1, -2, -3, -4, -5, -6, -7, -8, -9, -10,
]);
});
test('zip', () => {
expect(evaluate('={{ zip(["test1", "test2", "test3"], [1, 2, 3]) }}')).toEqual({
test1: 1,
test2: 2,
test3: 3,
});
});
test('$if', () => {
expect(evaluate('={{ $if("a"==="a", 1, 2) }}')).toEqual(1);
expect(evaluate('={{ $if("a"==="b", 1, 2) }}')).toEqual(2);
expect(evaluate('={{ $if("a"==="a", 1) }}')).toEqual(1);
expect(evaluate('={{ $if("a"==="b", 1) }}')).toEqual(false);
// This will likely break when sandboxing is implemented but it works for now.
// If you're implementing sandboxing maybe provide a way to add functions to
// sandbox we can check instead?
const mockCallback = vi.fn(() => false);
evaluate('={{ $if("a"==="a", true, $data.cb()) }}', [{ cb: mockCallback }]);
expect(mockCallback.mock.calls.length).toEqual(0);
evaluate('={{ $if("a"==="b", true, $data.cb()) }}', [{ cb: mockCallback }]);
expect(mockCallback.mock.calls.length).toEqual(1);
});
test('$not', () => {
expect(evaluate('={{ $not(1) }}')).toEqual(false);
expect(evaluate('={{ $not(0) }}')).toEqual(true);
expect(evaluate('={{ $not(true) }}')).toEqual(false);
expect(evaluate('={{ $not(false) }}')).toEqual(true);
expect(evaluate('={{ $not(undefined) }}')).toEqual(true);
expect(evaluate('={{ $not(null) }}')).toEqual(true);
expect(evaluate('={{ $not("") }}')).toEqual(true);
expect(evaluate('={{ $not("a") }}')).toEqual(false);
});
test('$ifEmpty', () => {
expect(evaluate('={{ $ifEmpty(1, "default") }}')).toEqual(1);
expect(evaluate('={{ $ifEmpty(0, "default") }}')).toEqual(0);
expect(evaluate('={{ $ifEmpty(false, "default") }}')).toEqual(false);
expect(evaluate('={{ $ifEmpty(true, "default") }}')).toEqual(true);
expect(evaluate('={{ $ifEmpty("", "default") }}')).toEqual('default');
expect(evaluate('={{ $ifEmpty(null, "default") }}')).toEqual('default');
expect(evaluate('={{ $ifEmpty(undefined, "default") }}')).toEqual('default');
expect(evaluate('={{ $ifEmpty([], "default") }}')).toEqual('default');
expect(evaluate('={{ $ifEmpty({}, "default") }}')).toEqual('default');
expect(evaluate('={{ $ifEmpty([1], "default") }}')).toEqual([1]);
expect(evaluate('={{ $ifEmpty({a: 1}, "default") }}')).toEqual({ a: 1 });
});
});
describe('Test extend with undefined', () => {
test('input is undefined', () => {
try {
extend(undefined, 'toDateTime', []);
} catch (error) {
expect(error).toBeInstanceOf(ExpressionExtensionError);
expect(error).toHaveProperty('message', "toDateTime can't be used on undefined value");
}
});
test('input is null', () => {
try {
extend(null, 'startsWith', []);
} catch (error) {
expect(error).toBeInstanceOf(ExpressionExtensionError);
expect(error).toHaveProperty('message', "startsWith can't be used on null value");
}
});
test('input should be converted to upper case', () => {
const result = extend('TEST', 'toUpperCase', []);
expect(result).toEqual('TEST');
});
});
});
@@ -0,0 +1,9 @@
import { evaluate } from './helpers';
describe('Data Transformation Functions', () => {
describe('Genric Data Transformation Functions', () => {
test('.isEmpty() should work correctly on undefined', () => {
expect(evaluate('={{(undefined).isEmpty()}}')).toEqual(true);
});
});
});
@@ -0,0 +1,42 @@
import type { IDataObject } from '../../src/interfaces';
import { Workflow } from '../../src/workflow';
import * as Helpers from '../helpers';
export const nodeTypes = Helpers.NodeTypes();
export const workflow = new Workflow({
nodes: [
{
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
export const expression = workflow.expression;
export const evaluate = (value: string, values?: IDataObject[]) =>
expression.getParameterValue(
value,
null,
0,
0,
'node',
values?.map((v) => ({ json: v })) ?? [],
'manual',
{},
);
export const getLocalISOString = (date: Date) => {
const offset = date.getTimezoneOffset();
const offsetAbs = Math.abs(offset);
const isoString = new Date(date.getTime() - offset * 60 * 1000).toISOString();
const hours = String(Math.floor(offsetAbs / 60)).padStart(2, '0');
const minutes = String(offsetAbs % 60).padStart(2, '0');
return `${isoString.slice(0, -1)}${offset > 0 ? '-' : '+'}${hours}:${minutes}`;
};
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { evaluate } from './helpers';
import { numberExtensions } from '../../src/extensions/number-extensions';
describe('Data Transformation Functions', () => {
describe('Number Data Transformation Functions', () => {
test('.format() should work correctly on a number', () => {
expect(evaluate('={{ Number(100).format() }}')).toEqual(
numberExtensions.functions.format(100, []),
);
});
test('.ceil() should work on a number', () => {
expect(evaluate('={{ (1.2).ceil() }}')).toEqual(2);
expect(evaluate('={{ (1.9).ceil() }}')).toEqual(2);
expect(evaluate('={{ (1.0).ceil() }}')).toEqual(1);
expect(evaluate('={{ (NaN).ceil() }}')).toBeNaN();
});
test('.floor() should work on a number', () => {
expect(evaluate('={{ (1.2).floor() }}')).toEqual(1);
expect(evaluate('={{ (1.9).floor() }}')).toEqual(1);
expect(evaluate('={{ (1.0).floor() }}')).toEqual(1);
expect(evaluate('={{ (NaN).floor() }}')).toBeNaN();
});
test('.round() should work on a number', () => {
expect(evaluate('={{ (1.3333333).round(3) }}')).toEqual(1.333);
expect(evaluate('={{ (1.3333333).round(0) }}')).toEqual(1);
expect(evaluate('={{ (1.5001).round(0) }}')).toEqual(2);
expect(evaluate('={{ (NaN).round(3) }}')).toBeNaN();
});
test('.isOdd() should work on a number', () => {
expect(evaluate('={{ (9).isOdd() }}')).toEqual(true);
expect(evaluate('={{ (8).isOdd() }}')).toEqual(false);
expect(evaluate('={{ (0).isOdd() }}')).toEqual(false);
});
test('.isOdd() should not work on a float or NaN', () => {
expect(() => evaluate('={{ (NaN).isOdd() }}')).toThrow();
expect(() => evaluate('={{ (9.2).isOdd() }}')).toThrow();
});
test('.isEven() should work on a number', () => {
expect(evaluate('={{ (9).isEven() }}')).toEqual(false);
expect(evaluate('={{ (8).isEven() }}')).toEqual(true);
expect(evaluate('={{ (0).isEven() }}')).toEqual(true);
});
test('.isEven() should not work on a float or NaN', () => {
expect(() => evaluate('={{ (NaN).isEven() }}')).toThrow();
expect(() => evaluate('={{ (9.2).isEven() }}')).toThrow();
});
describe('toDateTime', () => {
test('from milliseconds (default)', () => {
expect(evaluate('={{ (1704085200000).toDateTime().toISO() }}')).toEqual(
'2024-01-01T00:00:00.000-05:00',
);
expect(evaluate('={{ (1704085200000).toDateTime("ms").toISO() }}')).toEqual(
'2024-01-01T00:00:00.000-05:00',
);
});
test('from seconds', () => {
expect(evaluate('={{ (1704085200).toDateTime("s").toISO() }}')).toEqual(
'2024-01-01T00:00:00.000-05:00',
);
});
test('from Excel 1900 format', () => {
expect(evaluate('={{ (42144).toDateTime("excel").toISO() }}')).toEqual(
'2015-05-19T20:00:00.000-04:00',
);
});
test('from microseconds', () => {
expect(evaluate('={{ (1704085200000000).toDateTime("us").toISO() }}')).toEqual(
'2024-01-01T00:00:00.000-05:00',
);
});
});
describe('toInt', () => {
test('should round numbers', () => {
expect(evaluate('={{ (42144).toInt() }}')).toEqual(42144);
expect(evaluate('={{ (42144.345).toInt() }}')).toEqual(42144);
expect(evaluate('={{ (42144.545).toInt() }}')).toEqual(42145);
});
});
describe('toFloat', () => {
test('should return itself', () => {
expect(evaluate('={{ (42144).toFloat() }}')).toEqual(42144);
expect(evaluate('={{ (42144.345).toFloat() }}')).toEqual(42144.345);
});
});
describe('toBoolean', () => {
test('should return false for 0, 1 for other numbers', () => {
expect(evaluate('={{ (42144).toBoolean() }}')).toBe(true);
expect(evaluate('={{ (-1.549).toBoolean() }}')).toBe(true);
expect(evaluate('={{ (0).toBoolean() }}')).toBe(false);
});
});
});
describe('Multiple expressions', () => {
test('Basic multiple expressions', () => {
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
expect(evaluate('={{ "test abc".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual(
'test_abc you have $100.',
);
});
});
});
@@ -0,0 +1,184 @@
import { evaluate } from './helpers';
import { ApplicationError } from '../../src/errors';
import { objectExtensions } from '../../src/extensions/object-extensions';
describe('Data Transformation Functions', () => {
describe('Object Data Transformation Functions', () => {
describe('.isEmpty', () => {
test('should return true for an empty object', () => {
expect(evaluate('={{ ({}).isEmpty() }}')).toBe(true);
});
test('should return false for a non-empty object', () => {
expect(evaluate('={{ ({ test: 1 }).isEmpty() }}')).toBe(false);
});
test('should return true for an object with only null/undefined values', () => {
expect(evaluate('={{ ({ test1: null, test2: undefined }).isEmpty() }}')).toBe(false);
});
});
describe('.hasField', () => {
test('should return true if the key exists in the object', () => {
expect(evaluate('={{ ({ test1: 1 }).hasField("test1") }}')).toBe(true);
});
test('should return false if the key does not exist in the object', () => {
expect(evaluate('={{ ({ test1: 1 }).hasField("test2") }}')).toBe(false);
});
});
test('.removeField should work on an object', () => {
expect(evaluate('={{ ({ test1: 1, test2: 2, test3: 3 }).removeField("test2") }}')).toEqual({
test1: 1,
test3: 3,
});
expect(
evaluate('={{ ({ test1: 1, test2: 2, test3: 3 }).removeField("testDoesntExist") }}'),
).toEqual({
test1: 1,
test2: 2,
test3: 3,
});
});
describe('.removeFieldsContaining', () => {
test('should work on an object', () => {
expect(
evaluate(
'={{ ({ test1: "i exist", test2: "i should be removed", test3: "i should also be removed" }).removeFieldsContaining("removed") }}',
),
).toEqual({
test1: 'i exist',
});
});
test('should not work for empty string', () => {
expect(() =>
evaluate(
'={{ ({ test1: "i exist", test2: "i should be removed", test3: "i should also be removed" }).removeFieldsContaining("") }}',
),
).toThrow();
});
});
describe('.keepFieldsContaining', () => {
test('.keepFieldsContaining should work on an object', () => {
expect(
evaluate(
'={{ ({ test1: "i exist", test2: "i should be removed", test3: "i should also be removed" }).keepFieldsContaining("exist") }}',
),
).toEqual({
test1: 'i exist',
});
});
test('.keepFieldsContaining should work on a nested object', () => {
expect(
evaluate(
'={{ ({ test1: "i exist", test2: "i should be removed", test3: { test4: "me too" } }).keepFieldsContaining("exist") }}',
),
).toEqual({
test1: 'i exist',
});
});
test('.keepFieldsContaining should not work for empty string', () => {
expect(() =>
evaluate(
'={{ ({ test1: "i exist", test2: "i should be removed", test3: "i should also be removed" }).keepFieldsContaining("") }}',
),
).toThrow();
});
});
describe('.compact', () => {
test('should work on an object', () => {
expect(
evaluate('={{ ({ test1: 1, test2: "2", test3: undefined, test4: null }).compact() }}'),
).toEqual({ test1: 1, test2: '2' });
});
test('should remove fields with null, undefined, empty string, or "nil"', () => {
expect(
evaluate(
'={{ ({ test1: 0, test2: false, test3: "", test4: "nil", test5: NaN }).compact() }}',
),
).toEqual({ test1: 0, test2: false, test5: NaN });
});
test('should work on an empty object', () => {
expect(evaluate('={{ ({}).compact() }}')).toEqual({});
});
test('should work on an object with all null/undefined values', () => {
expect(evaluate('={{ ({ test1: undefined, test2: null }).compact() }}')).toEqual({});
});
test('should work on an object with nested null/undefined values', () => {
expect(
evaluate(
'={{ ({ test1: 1, test2: { nested1: null, nested2: "value" }, test3: undefined }).compact() }}',
),
).toEqual({ test1: 1, test2: { nested2: 'value' } });
});
test('should not allow prototype pollution', () => {
['{__proto__: {polluted: true}}', '{constructor: {prototype: {polluted: true}}}'].forEach(
(testExpression) => {
expect(() => evaluate(`={{ (${testExpression}).compact() }}`)).toThrow(
ApplicationError,
);
expect(({} as any).polluted).toBeUndefined();
},
);
});
});
test('.urlEncode should work on an object', () => {
expect(evaluate('={{ ({ test1: 1, test2: "2" }).urlEncode() }}')).toEqual('test1=1&test2=2');
});
describe('.keys', () => {
test('should return an array of keys from the object', () => {
expect(evaluate('={{ ({ test1: 1, test2: 2 }).keys() }}')).toEqual(['test1', 'test2']);
});
test('should return an empty array for an empty object', () => {
expect(evaluate('={{ ({}).keys() }}')).toEqual([]);
});
});
describe('.values', () => {
test('should return an array of values from the object', () => {
expect(evaluate('={{ ({ test1: 1, test2: "value" }).values() }}')).toEqual([1, 'value']);
});
test('should return an empty array for an empty object', () => {
expect(evaluate('={{ ({}).values() }}')).toEqual([]);
});
});
test('.toJsonString() should work on an object', () => {
expect(evaluate('={{ ({ test1: 1, test2: "2" }).toJsonString() }}')).toEqual(
'{"test1":1,"test2":"2"}',
);
});
describe('Conversion methods', () => {
test('should exist but return undefined (to not break expressions with mixed data)', () => {
expect(evaluate('={{ ({ test1: 1, test2: "2" }).toInt() }}')).toBeUndefined();
expect(evaluate('={{ ({ test1: 1, test2: "2" }).toFloat() }}')).toBeUndefined();
expect(evaluate('={{ ({ test1: 1, test2: "2" }).toBoolean() }}')).toBeUndefined();
expect(evaluate('={{ ({ test1: 1, test2: "2" }).toDateTime() }}')).toBeUndefined();
});
it('should not have a doc (hidden from autocomplete)', () => {
expect(objectExtensions.functions.toInt.doc).toBeUndefined();
expect(objectExtensions.functions.toFloat.doc).toBeUndefined();
expect(objectExtensions.functions.toBoolean.doc).toBeUndefined();
expect(objectExtensions.functions.toDateTime.doc).toBeUndefined();
});
});
});
});
@@ -0,0 +1,340 @@
// @vitest-environment jsdom
import { DateTime } from 'luxon';
import { evaluate } from './helpers';
import { ExpressionExtensionError } from '../../src/errors';
describe('Data Transformation Functions', () => {
describe('String Data Transformation Functions', () => {
describe('.isEmpty', () => {
test('should work correctly on a string that is not empty', () => {
expect(evaluate('={{"NotBlank".isEmpty()}}')).toEqual(false);
});
test('should work correctly on a string that is empty', () => {
expect(evaluate('={{"".isEmpty()}}')).toEqual(true);
});
});
describe('.isNotEmpty', () => {
test('should work correctly on a string that is not empty', () => {
expect(evaluate('={{"NotBlank".isNotEmpty()}}')).toEqual(true);
});
test('should work correctly on a string that is empty', () => {
expect(evaluate('={{"".isNotEmpty()}}')).toEqual(false);
});
});
test('.length should return the string length', () => {
expect(evaluate('={{"String".length()}}')).toEqual(6);
});
describe('.hash()', () => {
test.each([
['base64', 'MTIzNDU='],
['md5', '827ccb0eea8a706c4c34a16891f84e7b'],
['sha1', '8cb2237d0679ca88db6464eac60da96345513964'],
['sha224', 'a7470858e79c282bc2f6adfd831b132672dfd1224c1e78cbf5bcd057'],
['sha256', '5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5'],
[
'sha384',
'0fa76955abfa9dafd83facca8343a92aa09497f98101086611b0bfa95dbc0dcc661d62e9568a5a032ba81960f3e55d4a',
],
[
'sha512',
'3627909a29c31381a071ec27f7c9ca97726182aed29a7ddd2e54353322cfb30abb9e3a6df2ac2c20fe23436311d678564d0c8d305930575f60e2d3d048184d79',
],
[
'sha3',
'0a2a1719bf3ce682afdbedf3b23857818d526efbe7fcb372b31347c26239a0f916c398b7ad8dd0ee76e8e388604d0b0f925d5e913ad2d3165b9b35b3844cd5e6',
],
])('should work for %p', (hashFn, hashValue) => {
expect(evaluate(`={{ "12345".hash("${hashFn}") }}`)).toEqual(hashValue);
expect(evaluate(`={{ "12345".hash("${hashFn.toLowerCase()}") }}`)).toEqual(hashValue);
});
test('should throw on invalid algorithm', () => {
expect(() => evaluate('={{ "12345".hash("invalid") }}')).toThrow('Unknown algorithm');
});
});
test('.urlDecode should work correctly on a string', () => {
expect(evaluate('={{ "string%20with%20spaces".urlDecode(false) }}')).toEqual(
'string with spaces',
);
});
test('.urlEncode should work correctly on a string', () => {
expect(evaluate('={{ "string with spaces".urlEncode(false) }}')).toEqual(
'string%20with%20spaces',
);
});
test('.removeTags should work correctly on a string', () => {
expect(evaluate('={{ "<html><head>test</head></html>".removeTags() }}')).toEqual('test');
});
test('.removeMarkdown should work correctly on a string', () => {
expect(evaluate('={{ "<html><head>test</head></html>".removeMarkdown() }}')).toEqual('test');
});
test('.toLowerCase should work correctly on a string', () => {
expect(evaluate('={{ "TEST".toLowerCase() }}')).toEqual('test');
});
describe('.toDate', () => {
test('should work correctly on a date string', () => {
expect(evaluate('={{ "2022-09-01T19:42:28.164Z".toDate() }}')).toEqual(
new Date('2022-09-01T19:42:28.164Z'),
);
});
test('should throw on invalid date', () => {
expect(() => evaluate('={{ "2022-09-32T19:42:28.164Z".toDate() }}')).toThrow(
'cannot convert to date',
);
});
});
test('.toFloat should work correctly on a string', () => {
expect(evaluate('={{ "1.1".toFloat() }}')).toEqual(1.1);
expect(evaluate('={{ "1.1".toDecimalNumber() }}')).toEqual(1.1);
});
test('.toInt should work correctly on a string', () => {
expect(evaluate('={{ "1.1".toInt() }}')).toEqual(1);
expect(evaluate('={{ "1.1".toWholeNumber() }}')).toEqual(1);
expect(evaluate('={{ "1.5".toInt() }}')).toEqual(1);
expect(evaluate('={{ "1.5".toWholeNumber() }}')).toEqual(1);
});
test('.quote should work correctly on a string', () => {
expect(evaluate('={{ "test".quote() }}')).toEqual('"test"');
expect(evaluate('={{ "\\"test\\"".quote() }}')).toEqual('"\\"test\\""');
});
test('.isNumeric should work correctly on a string', () => {
expect(evaluate('={{ "".isNumeric() }}')).toEqual(false);
expect(evaluate('={{ "asdf".isNumeric() }}')).toEqual(false);
expect(evaluate('={{ "1234".isNumeric() }}')).toEqual(true);
expect(evaluate('={{ "4e4".isNumeric() }}')).toEqual(true);
expect(evaluate('={{ "4.4".isNumeric() }}')).toEqual(true);
});
test('.isUrl should work on a string', () => {
expect(evaluate('={{ "https://example.com/".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "http://example.com/".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "ftp://example.com/".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "example.com".isUrl() }}')).toEqual(false);
expect(evaluate('={{ "www.example.com".isUrl() }}')).toEqual(false);
expect(evaluate('={{ "https://www.example.com/".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "https://example.com/path".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "https://example.com/path?query=1".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "https://example.com/path#fragment".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "https://example.com:8080".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "https://example.com?query=1".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "https://example.com#fragment".isUrl() }}')).toEqual(true);
expect(evaluate('={{ "example.com/path".isUrl() }}')).toEqual(false);
expect(evaluate('={{ "http:///".isUrl() }}')).toEqual(false);
expect(evaluate('={{ "https://".isUrl() }}')).toEqual(false);
expect(evaluate('={{ "example".isUrl() }}')).toEqual(false);
expect(evaluate('={{ "".isUrl() }}')).toEqual(false);
});
test('.isDomain should work on a string', () => {
expect(evaluate('={{ "example.com".isDomain() }}')).toEqual(true);
expect(evaluate('={{ "asdf".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "https://example.com/".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "www.example.com".isDomain() }}')).toEqual(true);
expect(evaluate('={{ "subdomain.example.com".isDomain() }}')).toEqual(true);
expect(evaluate('={{ "example.co.uk".isDomain() }}')).toEqual(true);
expect(evaluate('={{ "example".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "example.".isDomain() }}')).toEqual(false);
expect(evaluate('={{ ".com".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "example..com".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "example_com".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "example/com".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "example com".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "www.example..com".isDomain() }}')).toEqual(false);
expect(evaluate('={{ "123.com".isDomain() }}')).toEqual(true);
expect(evaluate('={{ "xn--80aswg.xn--p1ai".isDomain() }}')).toEqual(true); // Punycode domain
expect(evaluate('={{ "example.com:8080".isDomain() }}')).toEqual(true);
expect(evaluate('={{ "".isDomain() }}')).toEqual(false);
});
test('.toSnakeCase should work on a string', () => {
expect(evaluate('={{ "I am a test!".toSnakeCase() }}')).toEqual('i_am_a_test');
expect(evaluate('={{ "i_am_a_test".toSnakeCase() }}')).toEqual('i_am_a_test');
});
test('.toSentenceCase should work on a string', () => {
expect(
evaluate(
'={{ "i am a test! i have multiple types of Punctuation. or do i?".toSentenceCase() }}',
),
).toEqual('I am a test! I have multiple types of punctuation. Or do i?');
expect(evaluate('={{ "i am a test!".toSentenceCase() }}')).toEqual('I am a test!');
expect(evaluate('={{ "i am a test".toSentenceCase() }}')).toEqual('I am a test');
});
test('.extractUrl should work on a string', () => {
expect(
evaluate(
'={{ "I am a test with a url: https://example.net/ and I am a test with an email: test@example.org".extractUrl() }}',
),
).toEqual('https://example.net/');
expect(
evaluate(
'={{ "Check this out: https://subdomain.example.com:3000/path?q=1#hash".extractUrl() }}',
),
).toEqual('https://subdomain.example.com:3000/path?q=1#hash');
expect(evaluate('={{ "Invalid URL: http:///example.com".extractUrl() }}')).toEqual(undefined);
expect(
evaluate(
'={{ "Mixed content: https://www.example.com and http://www.example.org".extractUrl() }}',
),
).toEqual('https://www.example.com');
expect(
evaluate('={{ "Text without URL: This is just a simple text".extractUrl() }}'),
).toEqual(undefined);
expect(
evaluate('={{ "URL with Unicode: http://www.xn--80aswg.xn--j1amh".extractUrl() }}'),
).toEqual('http://www.xn--80aswg.xn--j1amh');
expect(
evaluate('={{ "Localhost URL: http://localhost:8080/test?x=1".extractUrl() }}'),
).toEqual('http://localhost:8080/test?x=1');
expect(
evaluate('={{ "IP URL: http://192.168.1.1:8000/path?q=value#frag".extractUrl() }}'),
).toEqual('http://192.168.1.1:8000/path?q=value#frag');
});
test('.extractDomain should work on a string', () => {
expect(evaluate('={{ "test@example.org".extractDomain() }}')).toEqual('example.org');
expect(evaluate('={{ "https://example.org/".extractDomain() }}')).toEqual('example.org');
expect(evaluate('={{ "https://www.google.com".extractDomain() }}')).toEqual('www.google.com');
expect(evaluate('={{ "http://example.org".extractDomain() }}')).toEqual('example.org');
expect(evaluate('={{ "ftp://ftp.example.com".extractDomain() }}')).toEqual('ftp.example.com');
expect(evaluate('={{ "google.com".extractDomain() }}')).toEqual('google.com');
expect(evaluate('={{ "www.example.net".extractDomain() }}')).toEqual('www.example.net');
expect(evaluate('={{ "//example.com".extractDomain() }}')).toEqual('example.com');
expect(evaluate('={{ "mailto:john.doe@example.com".extractDomain() }}')).toEqual(
'example.com',
);
expect(evaluate('={{ "tel:+1-555-123-4567".extractDomain() }}')).toEqual(undefined);
expect(evaluate('={{ "jane.doe@example.org".extractDomain() }}')).toEqual('example.org');
expect(evaluate('={{ "name+tag@example.com".extractDomain() }}')).toEqual('example.com');
expect(evaluate('={{ "first.last@example.co.uk".extractDomain() }}')).toEqual(
'example.co.uk',
);
expect(evaluate('={{ "user@subdomain.example.com".extractDomain() }}')).toEqual(
'subdomain.example.com',
);
expect(evaluate('={{ "www.example.net?test=1213".extractDomain() }}')).toEqual(
'www.example.net',
);
expect(evaluate('={{ "www.example.net?test".extractDomain() }}')).toEqual('www.example.net');
expect(evaluate('={{ "www.example.net#tesdt123".extractDomain() }}')).toEqual(
'www.example.net',
);
expect(evaluate('={{ "https://www.example.net?test=1213".extractDomain() }}')).toEqual(
'www.example.net',
);
expect(evaluate('={{ "https://www.example.net?test".extractDomain() }}')).toEqual(
'www.example.net',
);
expect(evaluate('={{ "https://www.example.net#tesdt123".extractDomain() }}')).toEqual(
'www.example.net',
);
expect(evaluate('={{ "https://192.168.1.1".extractDomain() }}')).toEqual('192.168.1.1');
expect(evaluate('={{ "http://www.xn--80aswg.xn--j1amh".extractDomain() }}')).toEqual(
'www.xn--80aswg.xn--j1amh',
);
expect(evaluate('={{ "https://localhost".extractDomain() }}')).toEqual('localhost');
expect(evaluate('={{ "https://localhost?test=123".extractDomain() }}')).toEqual('localhost');
expect(evaluate('={{ "https://www.example_with_underscore.com".extractDomain() }}')).toEqual(
'www.example_with_underscore.com',
);
expect(evaluate('={{ "https://www.example.com:8080".extractDomain() }}')).toEqual(
'www.example.com',
);
expect(evaluate('={{ "https://example.space".extractDomain() }}')).toEqual('example.space');
});
test('.extractEmail should work on a string', () => {
expect(
evaluate(
'={{ "I am a test with a url: https://example.net/ and I am a test with an email: test@example.org".extractEmail() }}',
),
).toEqual('test@example.org');
});
test('.isEmail should work on a string', () => {
expect(evaluate('={{ "test@example.com".isEmail() }}')).toEqual(true);
expect(evaluate('={{ "aaaaaaaa".isEmail() }}')).toEqual(false);
expect(evaluate('={{ "test @ n8n".isEmail() }}')).toEqual(false);
});
test('.toDateTime should work on a variety of formats', () => {
expect(evaluate('={{ "Wed, 21 Oct 2015 07:28:00 GMT".toDateTime() }}')).toBeInstanceOf(
DateTime,
);
expect(evaluate('={{ "2008-11-11".toDateTime() }}')).toBeInstanceOf(DateTime);
expect(evaluate('={{ "1-Feb-2024".toDateTime() }}')).toBeInstanceOf(DateTime);
expect(evaluate('={{ "1713976144063".toDateTime("ms") }}')).toBeInstanceOf(DateTime);
expect(evaluate('={{ "31-01-2024".toDateTime("dd-MM-yyyy") }}')).toBeInstanceOf(DateTime);
vi.useFakeTimers({ now: new Date() });
expect(() => evaluate('={{ "hi".toDateTime() }}')).toThrow(
new ExpressionExtensionError('cannot convert to Luxon DateTime'),
);
vi.useRealTimers();
});
test('.extractUrlPath should work on a string', () => {
expect(
evaluate('={{ "https://example.com/orders/1/detail#hash?foo=bar".extractUrlPath() }}'),
).toEqual('/orders/1/detail');
expect(evaluate('={{ "hi".extractUrlPath() }}')).toBeUndefined();
});
test('.parseJson should work on a string', () => {
expect(evaluate('={{ \'{"test1":1,"test2":"2"}\'.parseJson() }}')).toEqual({
test1: 1,
test2: '2',
});
});
test('.parseJson should throw on invalid JSON', () => {
expect(() => evaluate("={{ \"{'test1':1,'test2':'2'}\".parseJson() }}")).toThrowError(
"Parsing failed. Check you're using double quotes",
);
expect(() => evaluate('={{ "No JSON here".parseJson() }}')).toThrowError('Parsing failed');
});
test('.toJsonString should work on a string', () => {
expect(evaluate('={{ "test".toJsonString() }}')).toEqual(JSON.stringify('test'));
expect(evaluate('={{ "The \\"best\\" colours: red\\nbrown".toJsonString() }}')).toEqual(
JSON.stringify('The "best" colours: red\nbrown'),
);
expect(evaluate('={{ "".toJsonString() }}')).toEqual(JSON.stringify(''));
});
test('.toBoolean should work on a string', () => {
expect(evaluate('={{ "False".toBoolean() }}')).toBe(false);
expect(evaluate('={{ "".toBoolean() }}')).toBe(false);
expect(evaluate('={{ "0".toBoolean() }}')).toBe(false);
expect(evaluate('={{ "no".toBoolean() }}')).toBe(false);
expect(evaluate('={{ "TRUE".toBoolean() }}')).toBe(true);
expect(evaluate('={{ "hello".toBoolean() }}')).toBe(true);
});
test('.base64Encode should work on a string', () => {
expect(evaluate('={{ "n8n test".base64Encode() }}')).toBe('bjhuIHRlc3Q=');
});
test('.base64Decode should work on a string', () => {
expect(evaluate('={{ "bjhuIHRlc3Q=".base64Decode() }}')).toBe('n8n test');
});
});
});
@@ -0,0 +1,815 @@
import { ExpressionError } from '../../src/errors/expression.error';
import type { GenericValue, IDataObject } from '../../src/interfaces';
interface ExpressionTestBase {
type: 'evaluation' | 'transform';
}
interface ExpressionTestSuccess extends ExpressionTestBase {
type: 'evaluation';
input: Array<IDataObject | GenericValue>;
output: IDataObject | GenericValue;
}
interface ExpressionTestFailure extends ExpressionTestBase {
type: 'evaluation';
input: Array<IDataObject | GenericValue>;
error: ExpressionError;
}
export interface ExpressionTestTransform extends ExpressionTestBase {
type: 'transform';
// If we don't specify a result we expect it to be the same as the input
result?: string;
forceTransform?: boolean;
}
export type ExpressionTestEvaluation = ExpressionTestSuccess | ExpressionTestFailure;
export type ExpressionTests = ExpressionTestEvaluation | ExpressionTestTransform;
export interface ExpressionTestFixture {
expression: string;
tests: ExpressionTests[];
}
export const baseFixtures: ExpressionTestFixture[] = [
{
expression: '={{$json["contact"]["FirstName"]}}',
tests: [
{
type: 'evaluation',
input: [{ contact: { FirstName: 'test' } }],
output: 'test',
},
{
type: 'evaluation',
input: [{ contact: null }],
output: undefined,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ $json["test"] }}',
tests: [
{
type: 'evaluation',
input: [{ test: 'value' }],
output: 'value',
},
{
type: 'evaluation',
input: [{ test: 1 }],
output: 1,
},
{
type: 'evaluation',
input: [{ test: null }],
output: null,
},
{
type: 'evaluation',
input: [{}],
output: undefined,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$json["test"].json["message"]["message_id"]}}',
tests: [
{
type: 'evaluation',
input: [{ test: { json: { message: { message_id: 'value' } } } }],
output: 'value',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$json[$json["Set2"].json["apiKey"]]}}',
tests: [
{
type: 'evaluation',
input: [{ Set2: { json: { apiKey: 'testKey' } }, testKey: 'testValue' }],
output: 'testValue',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$json["get"].json["recipes"][0]["image"]}}',
tests: [
{
type: 'evaluation',
input: [{ get: { json: { recipes: [{ image: 'test' }] } } }],
output: 'test',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
'=https://example.com/api/v1/workspaces/{{$json["Clockify1"].parameter["workspaceId"]}}/projects/{{$json["Clockify1"].json["id"]}}',
tests: [
{
type: 'evaluation',
input: [{ Clockify1: { parameter: { workspaceId: 'test1' }, json: { id: 'test2' } } }],
output: 'https://example.com/api/v1/workspaces/test1/projects/test2',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '= {{$json["dig check CF"].data["stdout"]}}',
tests: [
{
type: 'evaluation',
input: [{ 'dig check CF': { data: { stdout: 'testout' } } }],
output: ' testout',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$item(0).$json["Set URL"].json["base_domain"]}}{{$json["link"]}}',
tests: [
{
type: 'evaluation',
input: [{ 'Set URL': { json: { base_domain: 'left' } }, link: 'right' }],
output: 'leftright',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$runIndex}}',
tests: [
{
type: 'evaluation',
input: [],
output: 0,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ new String().toString() }}',
tests: [
{
type: 'evaluation',
input: [],
output: '',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
'={{(Date.parse($json["IF Zoom meeting"].json["end"]["dateTime"])-Date.parse($json["IF Zoom meeting"].json["start"]["dateTime"]))/(60*1000)}}',
tests: [
{
type: 'evaluation',
input: [
{
'IF Zoom meeting': {
json: {
end: { dateTime: '2023-02-09T13:32:54.187Z' },
start: { dateTime: '2023-02-09T13:22:54.187Z' },
},
},
},
],
output: 10,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$json["GetTicket"].json["tickets"].length}}',
tests: [
{
type: 'evaluation',
input: [{ GetTicket: { json: { tickets: [1, 2, 3, 4] } } }],
output: 4,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ $json.toString() }}',
tests: [
{
type: 'evaluation',
input: [{ test: 1 }],
output: '[object Object]',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{Math.floor(Math.min(1, 2) * 100);}}',
tests: [
{
type: 'evaluation',
input: [],
output: 100,
},
{
type: 'transform',
result: '={{extend(Math, "floor", [extend(Math, "min", [1, 2]) * 100])}}',
},
],
},
{
expression: '={{$json["\u56fe\u7247\u6570\u91cf\u5224\u65ad"].data["imgList"][0]}}',
tests: [
{
type: 'evaluation',
input: [
{
'\u56fe\u7247\u6570\u91cf\u5224\u65ad': {
data: { imgList: ['test'] },
},
},
],
output: 'test',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ $json["phone"] ?? 0}}',
tests: [
{
type: 'evaluation',
input: [{ phone: 'test' }],
output: 'test',
},
{
type: 'evaluation',
input: [{ phone: null }],
output: 0,
},
{
type: 'evaluation',
input: [{}],
output: 0,
},
{
type: 'evaluation',
input: [],
error: new ExpressionError("Node 'node' hasn't been executed", {
runIndex: 0,
itemIndex: -1,
type: 'no_execution_data',
functionality: 'pairedItem',
messageTemplate:
'An expression references this node, but the node is unexecuted. Consider re-wiring your nodes or checking for execution first, i.e. {{ $if( $("{{nodeName}}").isExecuted, <action_if_executed>, "") }}',
descriptionKey: 'pairedItemNoConnection',
nodeCause: 'node',
}),
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
"={{$json['Webhook1'].json[\"headers\"][\"x-api-key\"] +'-'+ new String('test').toString()}}",
tests: [
{
type: 'evaluation',
input: [{ Webhook1: { json: { headers: { 'x-api-key': 'left' } } } }],
output: 'left-test',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
'={{$json[\'Webhook1\'].json["headers"]["x-api-key"] +\'-\'+ parseInt($json.test)}}',
tests: [
{
type: 'evaluation',
input: [{ Webhook1: { json: { headers: { 'x-api-key': 'left' } } }, test: 3 }],
output: 'left-3',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ [].concat($json["Create or update"].json["vid"]) }}',
tests: [
{
type: 'evaluation',
input: [{ 'Create or update': { json: { vid: [1, 2, 3] } } }],
output: [1, 2, 3],
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '=https://example.com/test?id={{$json["Crypto"].json["data"].substr(0,6)}}',
tests: [
{
type: 'evaluation',
input: [{ Crypto: { json: { data: 'testtest' } } }],
output: 'https://example.com/test?id=testte',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ $json["body"]["project"]["name"].match(/\\[(\\d+)]/)[1] }}',
tests: [
{
type: 'evaluation',
input: [{ body: { project: { name: 'test[1234]' } } }],
output: '1234',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
'={{(new Date($json["end"]["date"]).getTime() - new Date($json["start"]["date"]).getTime()) / (1000 * 3600 * 24)}}',
tests: [
{
type: 'evaluation',
input: [
{
start: { date: '2023-02-09T13:22:54.187Z' },
end: { date: '2023-02-13T13:22:54.187Z' },
},
],
output: 4,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
'={{ $json["projectName"] == "" ? "Project Group " + ($json["projectsCount"] + 1) : $json["projectName"] }}',
tests: [
{
type: 'evaluation',
input: [{ projectName: '', projectsCount: 3 }],
output: 'Project Group 4',
},
{
type: 'evaluation',
input: [{ projectName: 'Project Test', projectsCount: 3 }],
output: 'Project Test',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{new Date($json["created_at"]).toISOString()}}',
tests: [
{
type: 'evaluation',
input: [{ created_at: '2023-02-09T13:22:54.187Z' }],
output: '2023-02-09T13:22:54.187Z',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$json["Find by ID1"].json["fields"]["clicks"]+1}}',
tests: [
{
type: 'evaluation',
input: [{ 'Find by ID1': { json: { fields: { clicks: 8 } } } }],
output: 9,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
'={{ (parseFloat($json["Bid"].replace(\',\', \'.\')) * parseFloat($json["Baserow"].json["Count"])).toFixed(2) }}',
tests: [
{
type: 'evaluation',
input: [{ Bid: '3,80', Baserow: { json: { Count: '10' } } }],
output: '38.00',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
'={\n\t"article": {\n\t\t"title": "{{$json["body"]["entry"]["Title"]}}",\n\t\t"published": true,\n\t\t"article_markdown": "{{$json["body"]["entry"]["PostContent"]}}",\n\t\t"tags":["{{$json["body"]["entry"]["Tag"]}}"]\n\t}\n}',
tests: [
{
type: 'evaluation',
input: [
{ body: { entry: { Title: 'title', PostContent: 'test contents', Tag: 'testTag' } } },
],
output: `{
"article": {
"title": "title",
"published": true,
"article_markdown": "test contents",
"tags":["testTag"]
}
}`,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression:
'={{$json["Find by ID"].json["id"] != "" && $json["Find by ID"].json["id"] != null && $json["Find by ID"].json["id"] != undefined}}',
tests: [
{
type: 'evaluation',
input: [{ 'Find by ID': { json: { id: 'test' } } }],
output: true,
},
{
type: 'evaluation',
input: [{ 'Find by ID': { json: { id: '' } } }],
output: false,
},
{
type: 'evaluation',
input: [{ 'Find by ID': { json: { id: null } } }],
output: false,
},
{
type: 'evaluation',
input: [{ 'Find by ID': { json: {} } }],
output: false,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$json["HTTP Request"].json["paging"] ? true : false}}',
tests: [
{
type: 'evaluation',
input: [{ 'HTTP Request': { json: { paging: 'test' } } }],
output: true,
},
{
type: 'evaluation',
input: [{ 'HTTP Request': { json: {} } }],
output: false,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{Math.min(1, 2);}}',
tests: [
{
type: 'evaluation',
input: [],
output: 1,
},
{ type: 'transform', result: '={{extend(Math, "min", [1, 2])}}' },
],
},
{
expression: '={{new String().toString();}}',
tests: [
{
type: 'evaluation',
input: [],
output: '',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true, result: '={{new String().toString()}}' },
],
},
{
expression: '={{ !!$json["different"]["name"] || !!$json["different"]["phone"] }}',
tests: [
{
type: 'evaluation',
input: [{ different: { name: 'test' } }],
output: true,
},
{
type: 'evaluation',
input: [{ different: { phone: 'test' } }],
output: true,
},
{
type: 'evaluation',
input: [{ different: {} }],
output: false,
},
{
type: 'evaluation',
input: [{ different: { phone: 'test', name: 'test2' } }],
output: true,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{200}}',
tests: [
{
type: 'evaluation',
input: [],
output: 200,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{$json.assetValue * $json.value / 100}}',
tests: [
{
type: 'evaluation',
input: [{ assetValue: 50, value: 50 }],
output: 25,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{/^\\d+$/.test($json["search_term"])}}',
tests: [
{
type: 'evaluation',
input: [{ search_term: '1234' }],
output: true,
},
{
type: 'evaluation',
input: [{ search_term: 'asdf' }],
output: false,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ `test\nvalue\nmulti\nline` }}',
tests: [
{
type: 'evaluation',
input: [],
output: `test
value
multi
line`,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ { "data": $json.body.choices } }}',
tests: [
{
type: 'evaluation',
input: [{ body: { choices: 'testValue' } }],
output: { data: 'testValue' },
},
{ type: 'transform' },
{
type: 'transform',
forceTransform: true,
result: '={{( { "data": $json.body.choices } )}}',
},
],
},
{
expression: '={{ $json["data"]["errors"] && $json["data"]["errors"].length > 0 }}',
tests: [
{
type: 'evaluation',
input: [{ data: { errors: [1, 2, 3, 4] } }],
output: true,
},
{
type: 'evaluation',
input: [{ data: { errors: [] } }],
output: false,
},
{
type: 'evaluation',
input: [{ data: {} }],
output: undefined,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{asdas}}',
tests: [
{
type: 'evaluation',
input: [],
output: undefined,
},
{
type: 'evaluation',
input: [{ asdas: 1 }],
output: undefined,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{!!$json["data"]["errors"]}}',
tests: [
{
type: 'evaluation',
input: [{ data: { errors: [] } }],
output: true,
},
{
type: 'evaluation',
input: [{ data: {} }],
output: false,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '=TRUE',
tests: [
{
type: 'evaluation',
input: [],
output: 'TRUE',
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ !$json?.data?.data?.issues?.pageInfo?.hasNextPage }}',
tests: [
{
type: 'evaluation',
input: [{ data: { data: { issues: { pageInfo: { hasNextPage: true } } } } }],
output: false,
},
{
type: 'evaluation',
input: [{ data: { data: { issues: { pageInfo: { hasNextPage: false } } } } }],
output: true,
},
{
type: 'evaluation',
input: [{ data: { data: { issues: { pageInfo: {} } } } }],
output: true,
},
{
type: 'evaluation',
input: [{ data: { data: { issues: {} } } }],
output: true,
},
{
type: 'evaluation',
input: [{ data: { data: {} } }],
output: true,
},
{
type: 'evaluation',
input: [{ data: {} }],
output: true,
},
{
type: 'evaluation',
input: [{}],
output: true,
},
{ type: 'transform' },
{
type: 'transform',
forceTransform: true,
result:
'={{ !(window.chainCancelToken1 = ((window.chainValue1 = $json) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainCancelToken1 = ((window.chainValue1 = window.chainValue1.data) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainCancelToken1 = ((window.chainValue1 = window.chainValue1.data) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainCancelToken1 = ((window.chainValue1 = window.chainValue1.issues) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainCancelToken1 = ((window.chainValue1 = window.chainValue1.pageInfo) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1.hasNextPage) }}',
},
],
},
{
expression: "={{ [{'name': 'something', 'batch_size':1000, 'ignore_cols':['x']}] }}",
tests: [
{
type: 'evaluation',
input: [],
output: [{ name: 'something', batch_size: 1000, ignore_cols: ['x'] }],
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{typeof $json["person"].json["name"] != "undefined"}}',
tests: [
{
type: 'evaluation',
input: [{ person: { json: { name: 'test' } } }],
output: true,
},
{
type: 'evaluation',
input: [{ person: { json: {} } }],
output: false,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: "={{ $json?.data == undefined ? '' : $json.data }}",
tests: [
{
type: 'evaluation',
input: [{ data: 1 }],
output: 1,
},
{
type: 'evaluation',
input: [{}],
output: '',
},
{ type: 'transform' },
{
type: 'transform',
forceTransform: true,
result:
"={{ (window.chainCancelToken1 = ((window.chainValue1 = $json) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1.data) == undefined ? '' : $json.data }}",
},
],
},
{
expression: "={{ 'domain' in $json && $json.domain != null}}",
tests: [
{
type: 'evaluation',
input: [{ domain: 1 }],
output: true,
},
{
type: 'evaluation',
input: [{ domain: null }],
output: false,
},
{
type: 'evaluation',
input: [{}],
output: false,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
{
expression: '={{ String("testing").length }}',
tests: [
{
type: 'evaluation',
input: [],
output: 7,
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },
],
},
];
@@ -0,0 +1,581 @@
import { augmentArray, augmentObject } from '../src/augment-object';
import type { IDataObject } from '../src/interfaces';
import { deepCopy } from '../src/utils';
describe('AugmentObject', () => {
describe('augmentArray', () => {
test('should work with arrays', () => {
const originalObject = [1, 2, 3, 4, null];
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentArray(originalObject);
expect(augmentedObject.constructor.name).toEqual('Array');
expect(augmentedObject.push(5)).toEqual(6);
expect(augmentedObject).toEqual([1, 2, 3, 4, null, 5]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.pop()).toEqual(5);
expect(augmentedObject).toEqual([1, 2, 3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.shift()).toEqual(1);
expect(augmentedObject).toEqual([2, 3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.unshift(1)).toEqual(5);
expect(augmentedObject).toEqual([1, 2, 3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.splice(1, 1)).toEqual([2]);
expect(augmentedObject).toEqual([1, 3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.slice(1)).toEqual([3, 4, null]);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.reverse()).toEqual([null, 4, 3, 1]);
expect(originalObject).toEqual(copyOriginal);
});
test('should work with arrays on any level', () => {
const originalObject = {
a: {
b: {
c: [
{
a3: {
b3: {
c3: '03' as string | null,
},
},
aa3: '01',
},
{
a3: {
b3: {
c3: '13',
},
},
aa3: '11',
},
],
},
},
aa: [
{
a3: {
b3: '2',
},
aa3: '1',
},
],
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
// On first level
augmentedObject.aa[0].a3.b3 = '22';
expect(augmentedObject.aa[0].a3.b3).toEqual('22');
expect(originalObject.aa[0].a3.b3).toEqual('2');
// Make sure that also array operations as push and length work as expected
// On lower levels
augmentedObject.a.b.c[0].a3.b3.c3 = '033';
expect(augmentedObject.a.b.c[0].a3.b3.c3).toEqual('033');
expect(originalObject.a.b.c[0].a3.b3.c3).toEqual('03');
augmentedObject.a.b.c[1].a3.b3.c3 = '133';
expect(augmentedObject.a.b.c[1].a3.b3.c3).toEqual('133');
expect(originalObject.a.b.c[1].a3.b3.c3).toEqual('13');
augmentedObject.a.b.c.push({
a3: {
b3: {
c3: '23',
},
},
aa3: '21',
});
augmentedObject.a.b.c[2].a3.b3.c3 = '233';
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual('233');
augmentedObject.a.b.c[2].a3.b3.c3 = '2333';
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual('2333');
augmentedObject.a.b.c[2].a3.b3.c3 = null;
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual(null);
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject.a.b.c.length).toEqual(3);
expect(augmentedObject.aa).toEqual([
{
a3: {
b3: '22',
},
aa3: '1',
},
]);
expect(augmentedObject.a.b.c).toEqual([
{
a3: {
b3: {
c3: '033',
},
},
aa3: '01',
},
{
a3: {
b3: {
c3: '133',
},
},
aa3: '11',
},
{
a3: {
b3: {
c3: null,
},
},
aa3: '21',
},
]);
expect(augmentedObject).toEqual({
a: {
b: {
c: [
{
a3: {
b3: {
c3: '033',
},
},
aa3: '01',
},
{
a3: {
b3: {
c3: '133',
},
},
aa3: '11',
},
{
a3: {
b3: {
c3: null,
},
},
aa3: '21',
},
],
},
},
aa: [
{
a3: {
b3: '22',
},
aa3: '1',
},
],
});
expect(originalObject).toEqual(copyOriginal);
});
});
describe('augmentObject', () => {
test('should work with simple values on first level', () => {
const date = new Date(1680089084200);
const regexp = new RegExp('^test$', 'ig');
const originalObject: IDataObject = {
1: 11,
2: '22',
a: 111,
b: '222',
d: date,
r: regexp,
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
expect(augmentedObject.constructor.name).toEqual('Object');
augmentedObject[1] = 911;
expect(originalObject[1]).toEqual(11);
expect(augmentedObject[1]).toEqual(911);
augmentedObject[2] = '922';
expect(originalObject[2]).toEqual('22');
expect(augmentedObject[2]).toEqual('922');
augmentedObject.a = 9111;
expect(originalObject.a).toEqual(111);
expect(augmentedObject.a).toEqual(9111);
augmentedObject.b = '9222';
expect(originalObject.b).toEqual('222');
expect(augmentedObject.b).toEqual('9222');
augmentedObject.c = 3;
expect({ ...originalObject, d: date.toJSON(), r: {} }).toEqual(copyOriginal);
expect(augmentedObject).toEqual({
1: 911,
2: '922',
a: 9111,
b: '9222',
c: 3,
d: date.toJSON(),
r: regexp.toString(),
});
});
test('should work with simple values on sub-level', () => {
const originalObject = {
a: {
b: {
cc: '3',
},
bb: '2',
},
aa: '1',
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
augmentedObject.a.bb = '92';
expect(originalObject.a.bb).toEqual('2');
expect(augmentedObject.a.bb).toEqual('92');
augmentedObject.a.b.cc = '93';
expect(originalObject.a.b.cc).toEqual('3');
expect(augmentedObject.a.b.cc).toEqual('93');
// @ts-ignore
augmentedObject.a.b.ccc = {
d: '4',
};
// @ts-ignore
expect(augmentedObject.a.b.ccc).toEqual({ d: '4' });
// @ts-ignore
augmentedObject.a.b.ccc.d = '94';
// @ts-ignore
expect(augmentedObject.a.b.ccc.d).toEqual('94');
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject).toEqual({
a: {
b: {
cc: '93',
ccc: {
d: '94',
},
},
bb: '92',
},
aa: '1',
});
});
test('should work with complex values on first level', () => {
const originalObject: any = {
a: {
b: {
cc: '3',
c2: null,
},
bb: '2',
},
aa: '1',
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
augmentedObject.a = { new: 'NEW' };
expect(originalObject.a).toEqual({
b: {
c2: null,
cc: '3',
},
bb: '2',
});
expect(augmentedObject.a).toEqual({ new: 'NEW' });
augmentedObject.aa = '11';
expect(originalObject.aa).toEqual('1');
expect(augmentedObject.aa).toEqual('11');
augmentedObject.aaa = {
bbb: {
ccc: '333',
},
};
expect(originalObject).toEqual(copyOriginal);
expect(augmentedObject).toEqual({
a: {
new: 'NEW',
},
aa: '11',
aaa: {
bbb: {
ccc: '333',
},
},
});
});
test('should work with delete and reset', () => {
const originalObject = {
a: {
b: {
c: {
d: '4' as string | undefined,
} as { d?: string; dd?: string } | undefined,
cc: '3' as string | undefined,
},
bb: '2' as string | undefined,
},
aa: '1' as string | undefined,
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
// Remove multiple values
delete augmentedObject.a.b.c!.d;
expect(augmentedObject.a.b.c!.d).toEqual(undefined);
expect(originalObject.a.b.c!.d).toEqual('4');
expect(augmentedObject).toEqual({
a: {
b: {
c: {},
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
delete augmentedObject.a.b.c;
expect(augmentedObject.a.b.c).toEqual(undefined);
expect(originalObject.a.b.c).toEqual({ d: '4' });
expect(augmentedObject).toEqual({
a: {
b: {
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
// Set deleted values again
augmentedObject.a.b.c = { dd: '444' };
expect(augmentedObject.a.b.c).toEqual({ dd: '444' });
expect(originalObject).toEqual(copyOriginal);
augmentedObject.a.b.c.d = '44';
expect(augmentedObject).toEqual({
a: {
b: {
c: {
d: '44',
dd: '444',
},
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
});
// Is almost identical to above test
test('should work with setting to undefined and reset', () => {
const originalObject = {
a: {
b: {
c: {
d: '4' as string | undefined,
} as { d?: string; dd?: string } | undefined,
cc: '3' as string | undefined,
},
bb: '2' as string | undefined,
},
aa: '1' as string | undefined,
};
const copyOriginal = deepCopy(originalObject);
const augmentedObject = augmentObject(originalObject);
// Remove multiple values
augmentedObject.a.b.c!.d = undefined;
expect(augmentedObject.a.b.c!.d).toEqual(undefined);
expect(originalObject.a.b.c!.d).toEqual('4');
expect(augmentedObject).toEqual({
a: {
b: {
c: {},
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
augmentedObject.a.b.c = undefined;
expect(augmentedObject.a.b.c).toEqual(undefined);
expect(originalObject.a.b.c).toEqual({ d: '4' });
expect(augmentedObject).toEqual({
a: {
b: {
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
// Set deleted values again
augmentedObject.a.b.c = { dd: '444' };
expect(augmentedObject.a.b.c).toEqual({ dd: '444' });
expect(originalObject).toEqual(copyOriginal);
augmentedObject.a.b.c.d = '44';
expect(augmentedObject).toEqual({
a: {
b: {
c: {
d: '44',
dd: '444',
},
cc: '3',
},
bb: '2',
},
aa: '1',
});
expect(originalObject).toEqual(copyOriginal);
});
test('should ignore non-enumerable keys', () => {
const originalObject = { a: 1, b: 2 };
Object.defineProperty(originalObject, '__hiddenProp', { enumerable: false });
const augmentedObject = augmentObject(originalObject);
expect(Object.keys(augmentedObject)).toEqual(['a', 'b']);
});
test('should return property descriptors', () => {
const originalObject: any = {
x: {
y: {},
z: {},
},
};
const augmentedObject = augmentObject(originalObject);
expect(Object.getOwnPropertyDescriptor(augmentedObject.x, 'y')).toEqual({
configurable: true,
enumerable: true,
value: {},
writable: true,
});
delete augmentedObject.x.y;
expect(augmentedObject.x.hasOwnProperty('y')).toEqual(false);
augmentedObject.x.y = 42;
expect(augmentedObject.x.hasOwnProperty('y')).toEqual(true);
expect(Object.getOwnPropertyDescriptor(augmentedObject.x, 'y')).toEqual({
configurable: true,
enumerable: true,
value: 42,
writable: true,
});
});
test('should return valid values on `has` calls', () => {
const originalObject: any = {
x: {
y: {},
},
};
const augmentedObject = augmentObject(originalObject);
expect('y' in augmentedObject.x).toBe(true);
expect('z' in augmentedObject.x).toBe(false);
augmentedObject.x.z = 5;
expect('z' in augmentedObject.x).toBe(true);
expect('y' in augmentedObject.x).toBe(true);
});
test('should ignore non-enumerable keys', () => {
const originalObject: { toString?: string } = { toString: '123' };
const augmentedObject = augmentObject(originalObject);
expect('toString' in augmentedObject).toBe(true);
expect(Object.keys(augmentedObject)).toEqual(['toString']);
expect(Object.getOwnPropertyDescriptor(augmentedObject, 'toString')?.value).toEqual(
originalObject.toString,
);
expect(augmentedObject.toString).toEqual(originalObject.toString);
augmentedObject.toString = '456';
expect(augmentedObject.toString).toBe('456');
delete augmentedObject.toString;
expect(augmentedObject.toString).toBeUndefined();
});
test('should handle constructor property correctly', () => {
const originalObject: any = {
a: {
b: {
c: {
d: '4',
},
},
},
};
const augmentedObject = augmentObject(originalObject);
expect(augmentedObject.constructor.name).toEqual('Object');
expect(augmentedObject.a.constructor.name).toEqual('Object');
expect(augmentedObject.a.b.constructor.name).toEqual('Object');
expect(augmentedObject.a.b.c.constructor.name).toEqual('Object');
augmentedObject.constructor = {};
expect(augmentedObject.constructor.name).toEqual('Object');
delete augmentedObject.constructor;
expect(augmentedObject.constructor.name).toEqual('Object');
});
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { IConnections, IConnection } from '../src/interfaces';
import { NodeConnectionTypes } from '../src/interfaces';
import { mapConnectionsByDestination } from '../src/common';
describe('getConnectionsByDestination', () => {
it('should return empty object when there are no connections', () => {
const result = mapConnectionsByDestination({});
expect(result).toEqual({});
});
it('should return connections by destination node', () => {
const connections: IConnections = {
Node1: {
[NodeConnectionTypes.Main]: [
[
{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 },
{ node: 'Node3', type: NodeConnectionTypes.Main, index: 1 },
],
],
},
};
const result = mapConnectionsByDestination(connections);
expect(result).toEqual({
Node2: {
[NodeConnectionTypes.Main]: [[{ node: 'Node1', type: NodeConnectionTypes.Main, index: 0 }]],
},
Node3: {
[NodeConnectionTypes.Main]: [
[],
[{ node: 'Node1', type: NodeConnectionTypes.Main, index: 0 }],
],
},
});
});
it('should handle multiple connection types', () => {
const connections: IConnections = {
Node1: {
[NodeConnectionTypes.Main]: [[{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }]],
[NodeConnectionTypes.AiAgent]: [
[{ node: 'Node3', type: NodeConnectionTypes.AiAgent, index: 0 }],
],
},
};
const result = mapConnectionsByDestination(connections);
expect(result).toEqual({
Node2: {
[NodeConnectionTypes.Main]: [[{ node: 'Node1', type: NodeConnectionTypes.Main, index: 0 }]],
},
Node3: {
[NodeConnectionTypes.AiAgent]: [
[{ node: 'Node1', type: NodeConnectionTypes.AiAgent, index: 0 }],
],
},
});
});
it('should handle nodes with no connections', () => {
const connections: IConnections = {
Node1: {
[NodeConnectionTypes.Main]: [[]],
},
};
const result = mapConnectionsByDestination(connections);
expect(result).toEqual({});
});
// @issue https://linear.app/n8n/issue/N8N-7880/cannot-load-some-templates
it('should handle nodes with null connections', () => {
const connections: IConnections = {
Node1: {
[NodeConnectionTypes.Main]: [
null as unknown as IConnection[],
[{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }],
],
},
};
const result = mapConnectionsByDestination(connections);
expect(result).toEqual({
Node2: {
[NodeConnectionTypes.Main]: [[{ node: 'Node1', type: NodeConnectionTypes.Main, index: 1 }]],
},
});
});
it('should handle nodes with multiple input connections', () => {
const connections: IConnections = {
Node1: {
[NodeConnectionTypes.Main]: [[{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }]],
},
Node3: {
[NodeConnectionTypes.Main]: [[{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }]],
},
};
const result = mapConnectionsByDestination(connections);
expect(result).toEqual({
Node2: {
[NodeConnectionTypes.Main]: [
[
{ node: 'Node1', type: NodeConnectionTypes.Main, index: 0 },
{ node: 'Node3', type: NodeConnectionTypes.Main, index: 0 },
],
],
},
});
});
});
@@ -0,0 +1,549 @@
import { mocked } from 'vitest-mock-extended';
import { type IConnection, type IConnections } from '../src';
import { compareConnections } from '../src/connections-diff';
// Mock IConnection for testing
const createConnection = (node: string, type: IConnection['type'], index: number): IConnection =>
mocked<IConnection>({
node,
type,
index,
});
describe('compareConnections', () => {
describe('empty states', () => {
it('should return empty diff when both prev and next are empty', () => {
const prev: IConnections = {};
const next: IConnections = {};
const result = compareConnections(prev, next);
expect(result.added).toEqual({});
expect(result.removed).toEqual({});
});
it('should detect all connections as added when prev is empty', () => {
const prev: IConnections = {};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node0', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({});
});
it('should detect all connections as removed when next is empty', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const next: IConnections = {};
const result = compareConnections(prev, next);
expect(result.added).toEqual({});
expect(result.removed).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node0', 'main', 0) },
},
],
},
});
});
});
describe('no changes', () => {
it('should return empty diff when connections are identical', () => {
const connections: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const result = compareConnections(connections, connections);
expect(result.added).toEqual({});
expect(result.removed).toEqual({});
});
it('should handle identical complex structures', () => {
const connections: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0), createConnection('node2', 'main', 0)]],
},
node2: {
main: [[createConnection('node1', 'main', 0)]],
},
};
const result = compareConnections(connections, connections);
expect(result.added).toEqual({});
expect(result.removed).toEqual({});
});
});
describe('simple additions and removals', () => {
it('should detect a single added connection', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0), createConnection('node2', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 1, connection: createConnection('node2', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({});
});
it('should detect a single removed connection', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0), createConnection('node2', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({});
expect(result.removed).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 1, connection: createConnection('node2', 'main', 0) },
},
],
},
});
});
it('should detect connection replacement', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node2', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node2', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node0', 'main', 0) },
},
],
},
});
});
});
describe('multiple nodes', () => {
it('should handle changes across multiple nodes', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
node2: {
main: [[createConnection('node1', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
node2: {
main: [[createConnection('node3', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node2: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node3', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({
node2: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node1', 'main', 0) },
},
],
},
});
});
it('should detect new node with connections', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
node2: {
main: [[createConnection('node1', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node2: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node1', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({});
});
it('should detect removed node with connections', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
node2: {
main: [[createConnection('node1', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({});
expect(result.removed).toEqual({
node2: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node1', 'main', 0) },
},
],
},
});
});
});
describe('multiple inputs', () => {
it('should handle multiple input types on same node', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
aux: [[createConnection('node2', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
aux: [[createConnection('node3', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node1: {
aux: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node3', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({
node1: {
aux: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node2', 'main', 0) },
},
],
},
});
});
it('should detect new input type', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
aux: [[createConnection('node2', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node1: {
aux: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node2', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({});
});
});
describe('multiple source indices', () => {
it('should handle multiple source indices (switch-like nodes)', () => {
const prev: IConnections = {
node1: {
main: [
[createConnection('node0', 'main', 0)],
null,
[createConnection('node2', 'main', 0)],
],
},
};
const next: IConnections = {
node1: {
main: [
[createConnection('node0', 'main', 0)],
[createConnection('node3', 'main', 0)],
[createConnection('node2', 'main', 0)],
],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node1: {
main: [
{
sourceIndex: 1,
value: { index: 0, connection: createConnection('node3', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({});
});
it('should detect removed connection at specific source index', () => {
const prev: IConnections = {
node1: {
main: [
[createConnection('node0', 'main', 0)],
[createConnection('node3', 'main', 0)],
[createConnection('node2', 'main', 0)],
],
},
};
const next: IConnections = {
node1: {
main: [
[createConnection('node0', 'main', 0)],
null,
[createConnection('node2', 'main', 0)],
],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({});
expect(result.removed).toEqual({
node1: {
main: [
{
sourceIndex: 1,
value: { index: 0, connection: createConnection('node3', 'main', 0) },
},
],
},
});
});
});
describe('complex scenarios', () => {
it('should handle multiple changes simultaneously', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
node2: {
main: [[createConnection('node1', 'main', 0)], [createConnection('node3', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0), createConnection('node4', 'main', 0)]],
},
node2: {
main: [[createConnection('node1', 'main', 0)]],
},
node3: {
main: [[createConnection('node2', 'main', 0)]],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 1, connection: createConnection('node4', 'main', 0) },
},
],
},
node3: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node2', 'main', 0) },
},
],
},
});
expect(result.removed).toEqual({
node2: {
main: [
{
sourceIndex: 1,
value: { index: 0, connection: createConnection('node3', 'main', 0) },
},
],
},
});
});
it('should handle connections with different indices but same node', () => {
const prev: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 0)]],
},
};
const next: IConnections = {
node1: {
main: [[createConnection('node0', 'main', 1)]],
},
};
const result = compareConnections(prev, next);
// These should be considered different connections
expect(result.added).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node0', 'main', 1) },
},
],
},
});
expect(result.removed).toEqual({
node1: {
main: [
{
sourceIndex: 0,
value: { index: 0, connection: createConnection('node0', 'main', 0) },
},
],
},
});
});
it('should handle empty arrays vs null', () => {
const prev: IConnections = {
node1: {
main: [[]],
},
};
const next: IConnections = {
node1: {
main: [null],
},
};
const result = compareConnections(prev, next);
expect(result.added).toEqual({});
expect(result.removed).toEqual({});
});
});
});
+76
View File
@@ -0,0 +1,76 @@
import { toCronExpression } from '../src/cron';
import type { CronExpression } from '../src/interfaces';
describe('Cron', () => {
describe('toCronExpression', () => {
test('should generate a valid cron for `everyMinute` triggers', () => {
const expression = toCronExpression({
mode: 'everyMinute',
});
expect(expression).toMatch(/^[1-5]?[0-9] \* \* \* \* \*$/);
});
test('should generate a valid cron for `everyHour` triggers', () => {
const expression = toCronExpression({
mode: 'everyHour',
minute: 11,
});
expect(expression).toMatch(/^[1-5]?[0-9] 11 \* \* \* \*$/);
});
test('should generate a valid cron for `everyX[minutes]` triggers', () => {
const expression = toCronExpression({
mode: 'everyX',
unit: 'minutes',
value: 42,
});
expect(expression).toMatch(/^[1-5]?[0-9] \*\/42 \* \* \* \*$/);
});
test('should generate a valid cron for `everyX[hours]` triggers', () => {
const expression = toCronExpression({
mode: 'everyX',
unit: 'hours',
value: 3,
});
expect(expression).toMatch(/^[1-5]?[0-9] [1-5]?[0-9] \*\/3 \* \* \*$/);
});
test('should generate a valid cron for `everyDay` triggers', () => {
const expression = toCronExpression({
mode: 'everyDay',
hour: 13,
minute: 17,
});
expect(expression).toMatch(/^[1-5]?[0-9] 17 13 \* \* \*$/);
});
test('should generate a valid cron for `everyWeek` triggers', () => {
const expression = toCronExpression({
mode: 'everyWeek',
hour: 13,
minute: 17,
weekday: 4,
});
expect(expression).toMatch(/^[1-5]?[0-9] 17 13 \* \* 4$/);
});
test('should generate a valid cron for `everyMonth` triggers', () => {
const expression = toCronExpression({
mode: 'everyMonth',
hour: 13,
minute: 17,
dayOfMonth: 12,
});
expect(expression).toMatch(/^[1-5]?[0-9] 17 13 12 \* \*$/);
});
test('should trim custom cron expressions', () => {
const expression = toCronExpression({
mode: 'custom',
cronExpression: ' 0 9-17 * * * ' as CronExpression,
});
expect(expression).toEqual('0 9-17 * * *');
});
});
});
@@ -0,0 +1,22 @@
import { createDeferredPromise } from '../src/deferred-promise';
describe('DeferredPromise', () => {
it('should resolve the promise with the correct value', async () => {
let done = false;
const deferred = createDeferredPromise<string>();
void deferred.promise.finally(() => {
done = true;
});
expect(done).toBe(false);
deferred.resolve('test');
await expect(deferred.promise).resolves.toBe('test');
expect(done).toBe(true);
});
it('should reject the promise with the correct error', async () => {
const deferred = createDeferredPromise();
const error = new Error('test error');
deferred.reject(error);
await expect(deferred.promise).rejects.toThrow(error);
});
});
@@ -0,0 +1,26 @@
import { BaseError } from '../../../src/errors/base/base.error';
import { OperationalError } from '../../../src/errors/base/operational.error';
describe('OperationalError', () => {
it('should be an instance of OperationalError', () => {
const error = new OperationalError('test');
expect(error).toBeInstanceOf(OperationalError);
});
it('should be an instance of BaseError', () => {
const error = new OperationalError('test');
expect(error).toBeInstanceOf(BaseError);
});
it('should have correct defaults', () => {
const error = new OperationalError('test');
expect(error.level).toBe('warning');
expect(error.shouldReport).toBe(false);
});
it('should allow overriding the default level and shouldReport', () => {
const error = new OperationalError('test', { level: 'error', shouldReport: true });
expect(error.level).toBe('error');
expect(error.shouldReport).toBe(true);
});
});
@@ -0,0 +1,26 @@
import { BaseError } from '../../../src/errors/base/base.error';
import { UnexpectedError } from '../../../src/errors/base/unexpected.error';
describe('UnexpectedError', () => {
it('should be an instance of UnexpectedError', () => {
const error = new UnexpectedError('test');
expect(error).toBeInstanceOf(UnexpectedError);
});
it('should be an instance of BaseError', () => {
const error = new UnexpectedError('test');
expect(error).toBeInstanceOf(BaseError);
});
it('should have correct defaults', () => {
const error = new UnexpectedError('test');
expect(error.level).toBe('error');
expect(error.shouldReport).toBe(true);
});
it('should allow overriding the default level and shouldReport', () => {
const error = new UnexpectedError('test', { level: 'fatal', shouldReport: false });
expect(error.level).toBe('fatal');
expect(error.shouldReport).toBe(false);
});
});
@@ -0,0 +1,26 @@
import { BaseError } from '../../../src/errors/base/base.error';
import { UserError } from '../../../src/errors/base/user.error';
describe('UserError', () => {
it('should be an instance of UserError', () => {
const error = new UserError('test');
expect(error).toBeInstanceOf(UserError);
});
it('should be an instance of BaseError', () => {
const error = new UserError('test');
expect(error).toBeInstanceOf(BaseError);
});
it('should have correct defaults', () => {
const error = new UserError('test');
expect(error.level).toBe('info');
expect(error.shouldReport).toBe(false);
});
it('should allow overriding the default level and shouldReport', () => {
const error = new UserError('test', { level: 'warning', shouldReport: true });
expect(error.level).toBe('warning');
expect(error.shouldReport).toBe(true);
});
});
@@ -0,0 +1,24 @@
import { mock } from 'vitest-mock-extended';
import { NodeApiError } from '../../src/errors/node-api.error';
import { NodeOperationError } from '../../src/errors/node-operation.error';
import type { INode } from '../../src/interfaces';
describe('NodeError', () => {
const node = mock<INode>();
it('should update re-wrapped error level and message', () => {
vi.useFakeTimers({ now: new Date() });
const apiError = new NodeApiError(node, { message: 'Some error happened', code: 500 });
const opsError = new NodeOperationError(node, mock(), { message: 'Some operation failed' });
const wrapped1 = new NodeOperationError(node, apiError);
const wrapped2 = new NodeOperationError(node, opsError);
expect(wrapped1.level).toEqual(apiError.level);
expect(wrapped1.message).toEqual(apiError.message);
expect(wrapped2).toEqual(opsError);
vi.useRealTimers();
});
});
@@ -0,0 +1,32 @@
import { WorkflowActivationError } from '../../src/errors';
describe('WorkflowActivationError', () => {
it('should default to `error` level', () => {
const error = new WorkflowActivationError('message');
expect(error.level).toBe('error');
});
const cause = new Error('Some error message');
it('should set `level` based on arg', () => {
const firstError = new WorkflowActivationError('message', { level: 'warning', cause });
expect(firstError.level).toBe('warning');
const secondError = new WorkflowActivationError('message', { level: 'error', cause });
expect(secondError.level).toBe('error');
});
test.each([
'ETIMEDOUT',
'ECONNREFUSED',
'EAUTH',
'Temporary authentication failure',
'Invalid credentials',
])('should set `level` to `warning` for `%s`', (code) => {
const error = new WorkflowActivationError(code, { cause });
expect(error.level).toBe('warning');
});
});
@@ -0,0 +1,994 @@
import { Tournament } from '@n8n/tournament';
import {
DollarSignValidator,
ThisSanitizer,
PrototypeSanitizer,
sanitizer,
DOLLAR_SIGN_ERROR,
} from '../src/expression-sandboxing';
import {
ExpressionClassExtensionError,
ExpressionComputedDestructuringError,
ExpressionDestructuringError,
ExpressionError,
ExpressionWithStatementError,
} from '../src/errors';
const tournament = new Tournament(
(e) => {
throw e;
},
undefined,
undefined,
{
before: [ThisSanitizer],
after: [PrototypeSanitizer, DollarSignValidator],
},
);
const errorRegex = /^Cannot access ".*" due to security concerns$/;
describe('PrototypeSanitizer', () => {
describe('Static analysis', () => {
it('should not allow access to __proto__', () => {
expect(() => {
tournament.execute('{{ ({}).__proto__.__proto__ }}', {});
}).toThrowError(errorRegex);
expect(() => {
tournament.execute('{{ ({})["__proto__"]["__proto__"] }}', {});
}).toThrowError(errorRegex);
});
it('should not allow access to prototype', () => {
expect(() => {
tournament.execute('{{ Number.prototype }}', { Number });
}).toThrowError(errorRegex);
expect(() => {
tournament.execute('{{ Number["prototype"] }}', { Number });
}).toThrowError(errorRegex);
});
it('should not allow access to constructor', () => {
expect(() => {
tournament.execute('{{ Number.constructor }}', {
__sanitize: sanitizer,
Number,
});
}).toThrowError(errorRegex);
expect(() => {
tournament.execute('{{ Number["constructor"] }}', {
__sanitize: sanitizer,
Number,
});
}).toThrowError(errorRegex);
});
it.each([
['dot notation', '{{ Error.prepareStackTrace }}'],
['bracket notation', '{{ Error["prepareStackTrace"] }}'],
['assignment', '{{ Error.prepareStackTrace = (e, s) => s }}'],
])('should not allow access to prepareStackTrace via %s', (_, expression) => {
expect(() => {
tournament.execute(expression, { __sanitize: sanitizer, Error });
}).toThrowError(errorRegex);
});
it.each([
['constructor', '{{ Number[`constructor`] }}', { Number }],
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
['constructor (Number)', '{{ Number[`constr${`uct`}or`] }}', { Number }],
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
['constructor (Object)', "{{ Object[`constr${'uct'}or`] }}", { Object }],
['__proto__', '{{ ({})[`__proto__`] }}', {}],
['mainModule', '{{ process[`mainModule`] }}', { process: {} }],
])('should not allow access to %s via template literal', (_, expression, context) => {
expect(() => {
tournament.execute(expression, { __sanitize: sanitizer, ...context });
}).toThrowError(errorRegex);
});
it.each([
['getPrototypeOf', '{{ Object.getPrototypeOf }}'],
['binding', '{{ process.binding }}'],
['_load', '{{ module._load }}'],
])('should not allow access to %s', (_, expression) => {
expect(() => {
tournament.execute(expression, { __sanitize: sanitizer, Object, process: {}, module: {} });
}).toThrowError(errorRegex);
});
it.each([
['dot notation', '{{ (()=>{}).caller }}'],
['bracket notation', '{{ (()=>{})["caller"] }}'],
])('should not allow access to caller via %s', (_, expression) => {
expect(() => {
tournament.execute(expression, { __sanitize: sanitizer });
}).toThrowError(errorRegex);
});
it.each([
['dot notation', '{{ (()=>{}).arguments }}'],
['bracket notation', '{{ (()=>{})["arguments"] }}'],
])('should not allow access to arguments via %s', (_, expression) => {
expect(() => {
tournament.execute(expression, { __sanitize: sanitizer });
}).toThrowError(errorRegex);
});
it.each([
['getBuiltinModule', '{{ ({}).getBuiltinModule }}'],
['_linkedBinding', '{{ ({})._linkedBinding }}'],
['dlopen', '{{ ({}).dlopen }}'],
['execve', '{{ ({}).execve }}'],
['loadEnvFile', '{{ ({}).loadEnvFile }}'],
])('should not allow access to %s', (_, expression) => {
expect(() => {
tournament.execute(expression, { __sanitize: sanitizer });
}).toThrowError(errorRegex);
});
describe('Dollar sign identifier handling', () => {
it('should not allow bare $ identifier', () => {
expect(() => {
tournament.execute('{{ $ }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
expect(() => {
tournament.execute('{{$}}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
});
it('should not allow $ in expressions', () => {
expect(() => {
tournament.execute('{{ "prefix" + $ }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
expect(() => {
tournament.execute('{{ $ + "suffix" }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
expect(() => {
tournament.execute('{{ 1 + $ }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
expect(() => {
tournament.execute('{{ [1, 2, $] }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
expect(() => {
tournament.execute('{{ {value: $} }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
});
it('should not allow $ with property access', () => {
expect(() => {
tournament.execute('{{ $.something }}', { $: { something: 'value' } });
}).toThrowError(DOLLAR_SIGN_ERROR);
expect(() => {
tournament.execute('{{ $["property"] }}', { $: { property: 'value' } });
}).toThrowError(DOLLAR_SIGN_ERROR);
});
it('should allow $ as function call', () => {
const mockFunction = () => 'result';
expect(() => {
tournament.execute('{{ $() }}', { $: mockFunction });
}).not.toThrow();
expect(() => {
tournament.execute('{{ $("node_name") }}', { $: mockFunction });
}).not.toThrow();
expect(() => {
tournament.execute('{{ $().someMethod() }}', { $: () => ({ someMethod: () => 'test' }) });
}).not.toThrow();
});
it('should allow $ in strings', () => {
expect(() => {
tournament.execute('{{ "test$test" }}', {});
}).not.toThrow();
expect(() => {
tournament.execute("{{ 'price: $100' }}", {});
}).not.toThrow();
expect(() => {
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
tournament.execute('{{ `template ${100}$` }}', {});
}).not.toThrow();
});
it('should allow $ as part of variable names', () => {
expect(() => {
tournament.execute('{{ $json }}', { $json: { test: 'value' } });
}).not.toThrow();
expect(() => {
tournament.execute('{{ price$ }}', { price$: 100 });
}).not.toThrow();
expect(() => {
tournament.execute('{{ my$var }}', { my$var: 'test' });
}).not.toThrow();
expect(() => {
tournament.execute('{{ _$_ }}', { _$_: 'underscore' });
}).not.toThrow();
});
it('should allow $ as a property name', () => {
// $ is a valid property name in JavaScript, so obj.$ should be allowed
expect(() => {
tournament.execute('{{ obj.$ }}', { obj: { $: 'value' } });
}).not.toThrow();
expect(() => {
tournament.execute('{{ data["$"] }}', { data: { $: 'value' } });
}).not.toThrow();
expect(() => {
const obj = { nested: { $: 'deep' } };
tournament.execute('{{ obj.nested.$ }}', { obj });
}).not.toThrow();
});
it('should allow $ in conditional expressions with function calls', () => {
const mockFunction = () => 'result';
expect(() => {
tournament.execute('{{ true ? $() : "fallback" }}', { $: mockFunction });
}).not.toThrow();
expect(() => {
tournament.execute('{{ $() || "default" }}', { $: mockFunction });
}).not.toThrow();
expect(() => {
tournament.execute('{{ $() && "continue" }}', { $: mockFunction });
}).not.toThrow();
});
it('should not allow $ in conditional expressions without function calls', () => {
expect(() => {
tournament.execute('{{ true ? $ : "fallback" }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
expect(() => {
tournament.execute('{{ $ || "default" }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
expect(() => {
tournament.execute('{{ $ && "continue" }}', { $: () => 'test' });
}).toThrowError(DOLLAR_SIGN_ERROR);
});
});
});
describe('Runtime', () => {
it('should not allow access to __proto__', () => {
expect(() => {
tournament.execute('{{ ({})["__" + (() => "proto")() + "__"] }}', {
__sanitize: sanitizer,
});
}).toThrowError(errorRegex);
});
it('should not allow access to prototype', () => {
expect(() => {
tournament.execute('{{ Number["pro" + (() => "toty")() + "pe"] }}', {
__sanitize: sanitizer,
Number,
});
}).toThrowError(errorRegex);
});
it('should not allow access to constructor', () => {
expect(() => {
tournament.execute('{{ Number["cons" + (() => "truc")() + "tor"] }}', {
__sanitize: sanitizer,
Number,
});
}).toThrowError(errorRegex);
});
it('should not allow access to caller via concatenation', () => {
expect(() => {
tournament.execute('{{ (()=>{})["cal" + "ler"] }}', {
__sanitize: sanitizer,
});
}).toThrowError(errorRegex);
});
it('should not allow access to arguments via concatenation', () => {
expect(() => {
tournament.execute('{{ (()=>{})["arg" + "uments"] }}', {
__sanitize: sanitizer,
});
}).toThrowError(errorRegex);
});
describe('Array-based property access bypass attempts', () => {
it('should not allow access to __proto__ via array', () => {
expect(() => {
tournament.execute('{{ ({})[["__proto__"]] }}', {
__sanitize: sanitizer,
});
}).toThrowError(errorRegex);
});
it('should not allow access to constructor via array', () => {
expect(() => {
tournament.execute('{{ ({})[["constructor"]] }}', {
__sanitize: sanitizer,
});
}).toThrowError(errorRegex);
});
it('should not allow access to prototype via array', () => {
expect(() => {
tournament.execute('{{ Number[["prototype"]] }}', {
__sanitize: sanitizer,
Number,
});
}).toThrowError(errorRegex);
});
it('should not allow prototype pollution via array access', () => {
expect(() => {
tournament.execute('{{ ({})[["__proto__"]].polluted = 1 }}', {
__sanitize: sanitizer,
});
}).toThrowError(errorRegex);
});
it('should not allow RCE via chained array access', () => {
expect(() => {
tournament.execute('{{ ({})[["toString"]][["constructor"]]("return 1")() }}', {
__sanitize: sanitizer,
});
}).toThrowError(errorRegex);
});
it('should not allow access to prepareStackTrace via array', () => {
expect(() => {
tournament.execute('{{ Error[["prepareStackTrace"]] }}', {
__sanitize: sanitizer,
Error,
});
}).toThrowError(errorRegex);
});
});
});
describe('Class extension bypass attempts', () => {
it('should not allow class extending Function', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Z extends Function {} return new Z("return 1")(); })() }}',
{ __sanitize: sanitizer },
);
}).toThrowError(ExpressionClassExtensionError);
});
it('should not allow class expression extending Function', () => {
expect(() => {
tournament.execute(
'{{ (() => { const Z = class extends Function {}; return new Z("return 1")(); })() }}',
{ __sanitize: sanitizer },
);
}).toThrowError(ExpressionClassExtensionError);
});
it('should not allow class extending GeneratorFunction', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Z extends GeneratorFunction {} return new Z("yield 1"); })() }}',
{ __sanitize: sanitizer },
);
}).toThrowError(ExpressionClassExtensionError);
});
it('should not allow class extending AsyncFunction', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Z extends AsyncFunction {} return new Z("return 1"); })() }}',
{ __sanitize: sanitizer },
);
}).toThrowError(ExpressionClassExtensionError);
});
it('should not allow class extending AsyncGeneratorFunction', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Z extends AsyncGeneratorFunction {} return new Z("yield 1"); })() }}',
{ __sanitize: sanitizer },
);
}).toThrowError(ExpressionClassExtensionError);
});
it('should allow class extending safe classes', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Child extends Array {} return new Child(1, 2, 3).length; })() }}',
{ __sanitize: sanitizer, Array },
);
}).not.toThrow();
});
it('should allow class without extends', () => {
expect(() => {
tournament.execute('{{ (() => { class MyClass {} return new MyClass(); })() }}', {
__sanitize: sanitizer,
});
}).not.toThrow();
});
it('should not allow class extending via CallExpression bypass', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Z extends (() => Function)() {} return new Z("return 1")(); })() }}',
{ __sanitize: sanitizer, Function },
);
}).toThrowError(ExpressionError);
});
it('should not allow class expression extending via CallExpression bypass', () => {
expect(() => {
tournament.execute(
'{{ (() => { const Z = class extends (() => Function)() {}; return new Z("return 1")(); })() }}',
{ __sanitize: sanitizer, Function },
);
}).toThrowError(ExpressionError);
});
it('should not allow class extending via ConditionalExpression bypass', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Z extends (true ? Function : Object) {} return new Z("return 1")(); })() }}',
{ __sanitize: sanitizer, Function, Object },
);
}).toThrowError(ExpressionError);
});
it('should not allow class extending via SequenceExpression bypass', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Z extends (0, Function) {} return new Z("return 1")(); })() }}',
{ __sanitize: sanitizer, Function },
);
}).toThrowError(ExpressionError);
});
it('should not allow class extending via LogicalExpression bypass', () => {
expect(() => {
tournament.execute(
'{{ (() => { class Z extends (Function || Object) {} return new Z("return 1")(); })() }}',
{ __sanitize: sanitizer, Function, Object },
);
}).toThrowError(ExpressionError);
});
});
describe('Destructuring patterns', () => {
it('should not allow destructuring constructor from arrow function', () => {
expect(() => {
tournament.execute(
'{{ (() => { const {constructor} = ()=>{}; return constructor; })() }}',
{
__sanitize: sanitizer,
},
);
}).toThrowError(ExpressionDestructuringError);
});
it('should not allow destructuring constructor from regular function', () => {
expect(() => {
tournament.execute(
'{{ (() => { const {constructor} = function(){}; return constructor; })() }}',
{
__sanitize: sanitizer,
},
);
}).toThrowError(ExpressionDestructuringError);
});
it('should not allow destructuring constructor with alias', () => {
expect(() => {
tournament.execute('{{ (() => { const {constructor: c} = ()=>{}; return c; })() }}', {
__sanitize: sanitizer,
});
}).toThrowError(ExpressionDestructuringError);
});
it('should not allow destructuring __proto__', () => {
expect(() => {
tournament.execute('{{ (() => { const {__proto__} = {}; return __proto__; })() }}', {
__sanitize: sanitizer,
});
}).toThrowError(ExpressionDestructuringError);
});
it('should not allow destructuring prototype', () => {
expect(() => {
tournament.execute(
'{{ (() => { const {prototype} = function(){}; return prototype; })() }}',
{
__sanitize: sanitizer,
},
);
}).toThrowError(ExpressionDestructuringError);
});
it('should not allow destructuring mainModule', () => {
expect(() => {
tournament.execute('{{ (() => { const {mainModule} = process; return mainModule; })() }}', {
__sanitize: sanitizer,
process: { mainModule: {} },
});
}).toThrowError(ExpressionDestructuringError);
});
it('should not allow destructuring caller', () => {
expect(() => {
tournament.execute('{{ (() => { const {caller} = ()=>{}; return caller; })() }}', {
__sanitize: sanitizer,
});
}).toThrowError(ExpressionDestructuringError);
});
it('should not allow destructuring arguments', () => {
expect(() => {
tournament.execute('{{ (() => { const {arguments: a} = function(){}; return a; })() }}', {
__sanitize: sanitizer,
});
}).toThrowError(ExpressionDestructuringError);
});
it('should allow destructuring safe properties', () => {
const result = tournament.execute(
'{{ (() => { const {name, value} = {name: "test", value: 42}; return name + value; })() }}',
{
__sanitize: sanitizer,
},
);
expect(result).toBe('test42');
});
it('should allow destructuring multiple safe properties', () => {
const result = tournament.execute(
'{{ (() => { const {a, b, c} = {a: 1, b: 2, c: 3}; return a + b + c; })() }}',
{
__sanitize: sanitizer,
},
);
expect(result).toBe(6);
});
it('should not allow computed property destructuring', () => {
expect(() => {
tournament.execute(
'{{ (() => { const a = "constructor"; const {[a]: c} = {}; return c; })() }}',
{
__sanitize: sanitizer,
},
);
}).toThrowError(ExpressionComputedDestructuringError);
});
});
describe('Spread-based global access', () => {
it('should not allow spreading process', () => {
expect(() => {
tournament.execute('{{ ((g) => g.getBuiltinModule)(({...process})) }}', {
__sanitize: sanitizer,
});
}).toThrowError(/due to security concerns/);
});
it('should not allow spreading process in object literal', () => {
expect(() => {
tournament.execute('{{ ({...process}) }}', { __sanitize: sanitizer });
}).toThrowError(/Cannot spread "process" due to security concerns/);
});
it('should not allow spreading process in array', () => {
expect(() => {
tournament.execute('{{ [...process] }}', { __sanitize: sanitizer });
}).toThrowError(/Cannot spread "process" due to security concerns/);
});
it('should not allow spreading global', () => {
expect(() => {
tournament.execute('{{ ({...global}) }}', { __sanitize: sanitizer });
}).toThrowError(/Cannot spread "global" due to security concerns/);
});
it('should not allow spreading Buffer', () => {
expect(() => {
tournament.execute('{{ ({...Buffer}) }}', { __sanitize: sanitizer });
}).toThrowError(/Cannot spread "Buffer" due to security concerns/);
});
it('should not allow the exact RCE PoC payload', () => {
expect(() => {
tournament.execute(
"{{ ((g) => g.getBuiltinModule('child_process').execSync('id').toString())({...process}) }}",
{ __sanitize: sanitizer },
);
}).toThrowError(/due to security concerns/);
});
it('should not allow spreading process in function call arguments', () => {
expect(() => {
tournament.execute('{{ ((a, b) => a)(...process) }}', { __sanitize: sanitizer });
}).toThrowError(/Cannot spread "process" due to security concerns/);
});
it('should not allow spreading process inside arrow function', () => {
expect(() => {
tournament.execute('{{ (() => ({...process}))() }}', { __sanitize: sanitizer });
}).toThrowError(/Cannot spread "process" due to security concerns/);
});
it('should not allow spreading process in nested spread', () => {
expect(() => {
tournament.execute('{{ ({...({...process})}) }}', { __sanitize: sanitizer });
}).toThrowError(/Cannot spread "process" due to security concerns/);
});
it('should not allow spreading process in template expression', () => {
expect(() => {
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
tournament.execute('{{ `${JSON.stringify({...process})}` }}', {
__sanitize: sanitizer,
});
}).toThrow();
});
it('should not allow spreading process among other spreads', () => {
expect(() => {
tournament.execute('{{ ({...{a:1}, ...process}) }}', { __sanitize: sanitizer });
}).toThrowError(/Cannot spread "process" due to security concerns/);
});
it('should resolve spread from data context process, not the real one', () => {
const result = tournament.execute('{{ ({...process}).safe }}', {
__sanitize: sanitizer,
process: { safe: true },
});
expect(result).toBe(true);
});
it('should not expose real process.version via spread', () => {
const result = tournament.execute('{{ typeof ({...process}).version }}', {
__sanitize: sanitizer,
process: {},
});
expect(result).toBe('undefined');
});
it('should use data context pid via spread, not real pid', () => {
const result = tournament.execute('{{ ({...process}).pid }}', {
__sanitize: sanitizer,
process: { pid: -1 },
});
expect(result).toBe(-1);
});
it('should use data context when spread is wrapped in arrow function', () => {
const result = tournament.execute('{{ ((g) => g.pid)({...process}) }}', {
__sanitize: sanitizer,
process: { pid: -1 },
});
expect(result).toBe(-1);
});
it('should not give access to real process.exit via spread', () => {
const result = tournament.execute('{{ typeof ({...process}).exit }}', {
__sanitize: sanitizer,
process: {},
});
expect(result).not.toBe('function');
});
it('should not give access to real process.env via spread', () => {
const result = tournament.execute('{{ typeof ({...process}).env }}', {
__sanitize: sanitizer,
process: {},
});
expect(result).not.toBe('object');
});
it('should not give access to getBuiltinModule via spread', () => {
let result: unknown;
try {
result = tournament.execute('{{ typeof ({...process}).getBuiltinModule }}', {
__sanitize: sanitizer,
process: {},
});
} catch {
// Blocked by PrototypeSanitizer — also a valid outcome
return;
}
expect(result).not.toBe('function');
});
});
describe('`with` statement', () => {
it('should not allow `with` statements', () => {
expect(() => {
tournament.execute('{{ (() => { with({}) { return 1; } })() }}', { __sanitize: sanitizer });
}).toThrowError(ExpressionWithStatementError);
});
it('should not allow constructor access via `with` statement', () => {
expect(() => {
tournament.execute(
'{{ (function(){ var constructor = 123; with(function(){}){ return constructor("return 1")() } })() }}',
{ __sanitize: sanitizer },
);
}).toThrowError(ExpressionWithStatementError);
});
it('should not allow RCE via with statement', () => {
expect(() => {
tournament.execute(
"{{ (function(){ var constructor = 123; with(function(){}){ return constructor(\"return process.mainModule.require('child_process').execSync('env').toString().trim()\")() } })() }}",
{
__sanitize: sanitizer,
},
);
}).toThrowError(ExpressionWithStatementError);
});
it('should not allow nested `with` statements', () => {
expect(() => {
tournament.execute('{{ (() => { with({a:1}) { with({b:2}) { return a + b; } } })() }}', {
__sanitize: sanitizer,
});
}).toThrowError(ExpressionWithStatementError);
});
it('should not allow `with` statement accessing prototype chain', () => {
expect(() => {
tournament.execute('{{ (() => { with(Object) { return getPrototypeOf({}); } })() }}', {
__sanitize: sanitizer,
Object,
});
}).toThrowError(ExpressionWithStatementError);
});
});
});
describe('ThisSanitizer', () => {
describe('call expression where callee is function expression', () => {
it('should transform call expression', () => {
const result = tournament.execute('{{ (function() { return this.process; })() }}', {
__sanitize: sanitizer,
});
expect(result).toEqual({});
});
it('should handle recursive call expression', () => {
const result = tournament.execute(
'{{ (function factorial(n) { return n <= 1 ? 1 : n * factorial(n - 1); })(5) }}',
{
__sanitize: sanitizer,
},
);
expect(result).toBe(120);
});
it('should not expose process.env through named function', () => {
const result = tournament.execute('{{ (function test(){ return this.process.env })() }}', {
__sanitize: sanitizer,
});
expect(result).toBe(undefined);
});
it('should still allow access to workflow data via variables', () => {
const result = tournament.execute('{{ (function() { return $json.value; })() }}', {
__sanitize: sanitizer,
$json: { value: 'test-value' },
});
expect(result).toBe('test-value');
});
it('should handle nested call expression', () => {
const result = tournament.execute(
'{{ (function() { return (function() { return this.process; })(); })() }}',
{
__sanitize: sanitizer,
},
);
expect(result).toEqual({});
});
it('should handle nested recursive call expression', () => {
const result = tournament.execute(
'{{ (function() { return (function factorial(n) { return n <= 1 ? 1 : n * factorial(n - 1); })(5); })() }}',
{
__sanitize: sanitizer,
},
);
expect(result).toBe(120);
});
});
describe('function expression', () => {
it('should transform function expression', () => {
const result = tournament.execute('{{ [1].map(function() { return this.process; }) }}', {
__sanitize: sanitizer,
});
expect(result).toEqual([{}]);
});
it('should handle recursive function expression', () => {
const result = tournament.execute(
'{{ [1, 2, 3, 4, 5].map(function factorial(n) { return n <= 1 ? 1 : n * factorial(n - 1); }) }}',
{
__sanitize: sanitizer,
},
);
expect(result).toEqual([1, 2, 6, 24, 120]);
});
it('should handle nested function expression', () => {
const result = tournament.execute(
'{{ [1, 2, 3].map(function(n) { return function() { return n * 2; }; }).map(function(fn) { return fn(); }) }}',
{
__sanitize: sanitizer,
},
);
expect(result).toEqual([2, 4, 6]);
});
it('should handle nested recursion', () => {
const result = tournament.execute(
'{{ (function fibonacci(n) { return n <= 1 ? n : fibonacci(n - 1) + fibonacci(n - 2); })(7) }}',
{
__sanitize: sanitizer,
},
);
expect(result).toBe(13);
});
});
describe('process.env security', () => {
it('should bind function expressions to empty process object', () => {
const processResult = tournament.execute('{{ (function(){ return this.process })() }}', {
__sanitize: sanitizer,
});
expect(processResult).toEqual({});
expect(Object.keys(processResult as object)).toEqual([]);
const envResult = tournament.execute('{{ (function(){ return this.process.env })() }}', {
__sanitize: sanitizer,
});
expect(envResult).toBe(undefined);
});
it('should block process.env in nested functions', () => {
const result = tournament.execute(
'{{ (function outer(){ return (function inner(){ return this.process.env })(); })() }}',
{
__sanitize: sanitizer,
},
);
expect(result).toBe(undefined);
});
it('should block process.env in callbacks', () => {
const result = tournament.execute(
'{{ [1].map(function(){ return this.process.env; })[0] }}',
{
__sanitize: sanitizer,
},
);
expect(result).toBe(undefined);
});
it('should still allow access to workflow variables', () => {
const result = tournament.execute('{{ (function(){ return $json.value })() }}', {
__sanitize: sanitizer,
$json: { value: 'workflow-data' },
});
expect(result).toBe('workflow-data');
});
});
describe('globalThis access via arrow functions', () => {
it('should replace globalThis with empty object', () => {
const result = tournament.execute('{{ (() => globalThis)() }}', {
__sanitize: sanitizer,
});
expect(result).toEqual({});
expect(result).not.toBe(globalThis);
});
it('should block process.env access via globalThis', () => {
const result = tournament.execute('{{ (() => globalThis.process)() }}', {
__sanitize: sanitizer,
});
expect(result).toBe(undefined);
});
it('should block chained globalThis access', () => {
const result = tournament.execute('{{ ((g) => g.process)((() => globalThis)()) }}', {
__sanitize: sanitizer,
});
expect(result).toBe(undefined);
});
it('should block env access via nested arrow functions', () => {
// This payload attempts to access process.env via chained arrow functions
// With the fix, globalThis becomes {}, so g.process is undefined,
// and accessing .env on undefined throws an error - which is the desired security outcome
expect(() => {
tournament.execute('{{ ((p) => p.env)(((g) => g.process)((() => globalThis)())) }}', {
__sanitize: sanitizer,
});
}).toThrow();
});
it('should replace globalThis with empty object in non-arrow contexts too', () => {
// globalThis is replaced with {} at AST level, regardless of context
const result = tournament.execute('{{ globalThis }}', {
__sanitize: sanitizer,
});
expect(result).toEqual({});
});
it('should still allow access to workflow data via variables', () => {
const result = tournament.execute('{{ (() => $json.value)() }}', {
__sanitize: sanitizer,
$json: { value: 'test-value' },
});
expect(result).toBe('test-value');
});
});
describe('this access via arrow functions', () => {
it('should replace this with safe context in arrow functions', () => {
const result = tournament.execute('{{ (() => this)() }}', {
__sanitize: sanitizer,
});
expect(result).toEqual({ process: {}, require: {}, module: {}, Buffer: {} });
});
it('should block process.env access via this in arrow functions', () => {
const result = tournament.execute('{{ (() => this?.process)() }}', {
__sanitize: sanitizer,
});
expect(result).toEqual({});
expect(result).not.toHaveProperty('env');
});
it('should block this access in nested arrow functions', () => {
const result = tournament.execute('{{ (() => (() => this)())() }}', {
__sanitize: sanitizer,
});
expect(result).toEqual({ process: {}, require: {}, module: {}, Buffer: {} });
});
it('should block this?.process?.env access pattern', () => {
const result = tournament.execute('{{ (() => this?.process?.env)() }}', {
__sanitize: sanitizer,
});
expect(result).toBe(undefined);
});
it('should still work with this in regular function expressions', () => {
const result = tournament.execute('{{ (function() { return this.process; })() }}', {
__sanitize: sanitizer,
});
expect(result).toEqual({});
});
});
});
+944
View File
@@ -0,0 +1,944 @@
// @vitest-environment jsdom
import { DateTime, Duration, Interval } from 'luxon';
import { workflow } from './ExpressionExtensions/helpers';
import { baseFixtures } from './ExpressionFixtures/base';
import type { ExpressionTestEvaluation, ExpressionTestTransform } from './ExpressionFixtures/base';
import * as Helpers from './helpers';
import { ExpressionReservedVariableError } from '../src/errors/expression-reserved-variable.error';
import { ExpressionError } from '../src/errors/expression.error';
import { Expression } from '../src/expression';
import { extendSyntax } from '../src/extensions/expression-extension';
import type { INodeExecutionData } from '../src/interfaces';
import { Workflow } from '../src/workflow';
import { WorkflowDataProxy } from '../src/workflow-data-proxy';
describe('Expression', () => {
describe('getParameterValue()', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
id: '1',
nodes: [
{
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
const expression = workflow.expression;
const evaluate = (value: string) =>
expression.getParameterValue(value, null, 0, 0, 'node', [], 'manual', {});
it('should not be able to use global built-ins from denylist', () => {
expect(evaluate('={{document}}')).toEqual({});
expect(evaluate('={{window}}')).toEqual({});
expect(evaluate('={{Window}}')).toEqual({});
expect(evaluate('={{globalThis}}')).toEqual({});
expect(evaluate('={{self}}')).toEqual({});
expect(evaluate('={{alert}}')).toEqual({});
expect(evaluate('={{prompt}}')).toEqual({});
expect(evaluate('={{confirm}}')).toEqual({});
expect(evaluate('={{eval}}')).toEqual({});
expect(evaluate('={{uneval}}')).toEqual({});
expect(evaluate('={{setTimeout}}')).toEqual({});
expect(evaluate('={{setInterval}}')).toEqual({});
expect(evaluate('={{Function}}')).toEqual({});
expect(evaluate('={{fetch}}')).toEqual({});
expect(evaluate('={{XMLHttpRequest}}')).toEqual({});
expect(evaluate('={{Promise}}')).toEqual({});
expect(evaluate('={{Generator}}')).toEqual({});
expect(evaluate('={{GeneratorFunction}}')).toEqual({});
expect(evaluate('={{AsyncFunction}}')).toEqual({});
expect(evaluate('={{AsyncGenerator}}')).toEqual({});
expect(evaluate('={{AsyncGeneratorFunction}}')).toEqual({});
expect(evaluate('={{WebAssembly}}')).toEqual({});
expect(evaluate('={{Reflect}}')).toEqual({});
expect(evaluate('={{Proxy}}')).toEqual({});
vi.useFakeTimers({ now: new Date() });
expect(() => evaluate('={{constructor}}')).toThrowError(
new ExpressionError('Cannot access "constructor" due to security concerns'),
);
vi.useRealTimers();
expect(evaluate('={{escape}}')).toEqual({});
expect(evaluate('={{unescape}}')).toEqual({});
});
it('should be able to use global built-ins from allowlist', () => {
expect(evaluate('={{new Date()}}')).toBeInstanceOf(Date);
expect(evaluate('={{DateTime.now().toLocaleString()}}')).toEqual(
DateTime.now().toLocaleString(),
);
vi.useFakeTimers({ now: new Date() });
expect(evaluate('={{Interval.after(new Date(), 100)}}')).toEqual(
Interval.after(new Date(), 100),
);
vi.useRealTimers();
expect(evaluate('={{Duration.fromMillis(100)}}')).toEqual(Duration.fromMillis(100));
expect(evaluate('={{new Object()}}')).toEqual(new Object());
expect(evaluate('={{new Array()}}')).toEqual([]);
expect(evaluate('={{new Int8Array()}}')).toEqual(new Int8Array());
expect(evaluate('={{new Uint8Array()}}')).toEqual(new Uint8Array());
expect(evaluate('={{new Uint8ClampedArray()}}')).toEqual(new Uint8ClampedArray());
expect(evaluate('={{new Int16Array()}}')).toEqual(new Int16Array());
expect(evaluate('={{new Uint16Array()}}')).toEqual(new Uint16Array());
expect(evaluate('={{new Int32Array()}}')).toEqual(new Int32Array());
expect(evaluate('={{new Uint32Array()}}')).toEqual(new Uint32Array());
expect(evaluate('={{new Float32Array()}}')).toEqual(new Float32Array());
expect(evaluate('={{new Float64Array()}}')).toEqual(new Float64Array());
expect(evaluate('={{new BigInt64Array()}}')).toEqual(new BigInt64Array());
expect(evaluate('={{new BigUint64Array()}}')).toEqual(new BigUint64Array());
expect(evaluate('={{new Map()}}')).toEqual(new Map());
expect(evaluate('={{new WeakMap()}}')).toEqual(new WeakMap());
expect(evaluate('={{new Set()}}')).toEqual(new Set());
expect(evaluate('={{new WeakSet()}}')).toEqual(new WeakSet());
expect(evaluate('={{new Error()}}')).toEqual(new Error());
expect(evaluate('={{new TypeError()}}')).toEqual(new TypeError());
expect(evaluate('={{new SyntaxError()}}')).toEqual(new SyntaxError());
expect(evaluate('={{new EvalError()}}')).toEqual(new EvalError());
expect(evaluate('={{new RangeError()}}')).toEqual(new RangeError());
expect(evaluate('={{new ReferenceError()}}')).toEqual(new ReferenceError());
expect(evaluate('={{new URIError()}}')).toEqual(new URIError());
expect(evaluate('={{Intl}}')).toEqual(Intl);
expect(evaluate('={{new String()}}')).toEqual(new String());
expect(evaluate("={{new RegExp('')}}")).toEqual(new RegExp(''));
expect(evaluate('={{Math}}')).toEqual(Math);
expect(evaluate('={{new Number()}}')).toEqual(new Number());
expect(evaluate("={{BigInt('1')}}")).toEqual(BigInt('1'));
expect(evaluate('={{Infinity}}')).toEqual(Infinity);
expect(evaluate('={{NaN}}')).toEqual(NaN);
expect(evaluate('={{isFinite(1)}}')).toEqual(isFinite(1));
expect(evaluate('={{isNaN(1)}}')).toEqual(isNaN(1));
expect(evaluate("={{parseFloat('1')}}")).toEqual(parseFloat('1'));
expect(evaluate("={{parseInt('1', 10)}}")).toEqual(parseInt('1', 10));
expect(evaluate('={{JSON.stringify({})}}')).toEqual(JSON.stringify({}));
expect(evaluate('={{new ArrayBuffer(10)}}')).toEqual(new ArrayBuffer(10));
expect(evaluate('={{new SharedArrayBuffer(10)}}')).toEqual(new SharedArrayBuffer(10));
expect(evaluate('={{Atomics}}')).toEqual(Atomics);
expect(evaluate('={{new DataView(new ArrayBuffer(1))}}')).toEqual(
new DataView(new ArrayBuffer(1)),
);
expect(evaluate("={{encodeURI('https://google.com')}}")).toEqual(
encodeURI('https://google.com'),
);
expect(evaluate("={{encodeURIComponent('https://google.com')}}")).toEqual(
encodeURIComponent('https://google.com'),
);
expect(evaluate("={{decodeURI('https://google.com')}}")).toEqual(
decodeURI('https://google.com'),
);
expect(evaluate("={{decodeURIComponent('https://google.com')}}")).toEqual(
decodeURIComponent('https://google.com'),
);
expect(evaluate('={{Boolean(1)}}')).toEqual(Boolean(1));
expect(evaluate('={{Symbol(1).toString()}}')).toEqual(Symbol(1).toString());
});
it('should not able to do arbitrary code execution', () => {
const testFn = vi.fn();
Object.assign(global, { testFn });
vi.useFakeTimers({ now: new Date() });
expect(() => evaluate("={{ Date['constructor']('testFn()')()}}")).toThrowError(
new ExpressionError('Cannot access "constructor" due to security concerns'),
);
vi.useRealTimers();
expect(testFn).not.toHaveBeenCalled();
});
it('should include runIndex and itemIndex in error when .constructor is used', () => {
let thrownError: ExpressionError | undefined;
try {
expression.getParameterValue(
'={{ {}.constructor() }}',
null,
2,
3,
'node',
[],
'manual',
{},
);
} catch (e) {
thrownError = e as ExpressionError;
}
expect(thrownError).toBeInstanceOf(ExpressionError);
expect(thrownError?.context.runIndex).toBe(2);
expect(thrownError?.context.itemIndex).toBe(3);
});
describe('SafeObject security wrapper', () => {
it('should block Object.defineProperty', () => {
expect(evaluate('={{Object.defineProperty}}')).toBeUndefined();
});
it('should block Object.defineProperties', () => {
expect(evaluate('={{Object.defineProperties}}')).toBeUndefined();
});
it('should block Object.setPrototypeOf', () => {
expect(evaluate('={{Object.setPrototypeOf}}')).toBeUndefined();
});
it('should block Object.getPrototypeOf', () => {
expect(() => evaluate('={{Object.getPrototypeOf}}')).toThrow();
});
it('should block Object.getOwnPropertyDescriptor', () => {
expect(evaluate('={{Object.getOwnPropertyDescriptor}}')).toBeUndefined();
});
it('should block Object.getOwnPropertyDescriptors', () => {
expect(evaluate('={{Object.getOwnPropertyDescriptors}}')).toBeUndefined();
});
it('should block __defineGetter__ on Object', () => {
expect(() => evaluate('={{Object.__defineGetter__}}')).toThrow(
'Cannot access "__defineGetter__" due to security concerns',
);
});
it('should block __defineSetter__ on Object', () => {
expect(() => evaluate('={{Object.__defineSetter__}}')).toThrow(
'Cannot access "__defineSetter__" due to security concerns',
);
});
it('should block __lookupGetter__ on Object', () => {
expect(() => evaluate('={{Object.__lookupGetter__}}')).toThrow(
'Cannot access "__lookupGetter__" due to security concerns',
);
});
it('should block __lookupSetter__ on Object', () => {
expect(() => evaluate('={{Object.__lookupSetter__}}')).toThrow(
'Cannot access "__lookupSetter__" due to security concerns',
);
});
it('should allow safe Object methods', () => {
expect(evaluate('={{Object.keys({a: 1})}}')).toEqual(['a']);
expect(evaluate('={{Object.values({a: 1})}}')).toEqual([1]);
expect(evaluate('={{Object.entries({a: 1})}}')).toEqual([['a', 1]]);
expect(evaluate('={{Object.assign({}, {a: 1})}}')).toEqual({ a: 1 });
expect(evaluate('={{Object.fromEntries([["a", 1]])}}')).toEqual({ a: 1 });
expect(evaluate('={{Object.is(1, 1)}}')).toEqual(true);
expect(evaluate('={{Object.hasOwn({a: 1}, "a")}}')).toEqual(true);
});
it('should allow Object.create with single argument', () => {
// Object.create with null prototype
expect(evaluate('={{Object.create(null) !== null}}')).toEqual(true);
});
it('should prevent Object.defineProperty attack on Error.prepareStackTrace', () => {
// Object.defineProperty is undefined, so calling it returns undefined (no-op)
// The attack fails silently - prepareStackTrace is never set
const result = evaluate(
"={{Object.defineProperty(Error, 'prepareStackTrace', { value: (e, s) => s })}}",
);
expect(result).toBeUndefined();
});
});
describe('SafeError security wrapper', () => {
it('should block Error.prepareStackTrace access', () => {
expect(() => evaluate('={{Error.prepareStackTrace}}')).toThrow();
});
it('should block Error.captureStackTrace access', () => {
// captureStackTrace is blocked by the SafeError proxy, returns undefined
expect(evaluate('={{Error.captureStackTrace}}')).toBeUndefined();
});
it('should block Error.stackTraceLimit access', () => {
// stackTraceLimit is blocked by the SafeError proxy, returns undefined
expect(evaluate('={{Error.stackTraceLimit}}')).toBeUndefined();
});
it('should block __defineGetter__ on Error', () => {
expect(() => evaluate('={{Error.__defineGetter__}}')).toThrow(
'Cannot access "__defineGetter__" due to security concerns',
);
});
it('should block __defineSetter__ on Error', () => {
expect(() => evaluate('={{Error.__defineSetter__}}')).toThrow(
'Cannot access "__defineSetter__" due to security concerns',
);
});
it('should prevent setting Error.prepareStackTrace via assignment', () => {
// Assignment fails because the sanitizer blocks access to prepareStackTrace
expect(() =>
evaluate('={{Error.prepareStackTrace = (e, s) => s, Error.prepareStackTrace}}'),
).toThrow();
});
it('should allow normal Error functionality', () => {
expect(evaluate('={{new Error("test").message}}')).toEqual('test');
expect(evaluate('={{new Error("test") instanceof Error}}')).toEqual(true);
});
});
describe('Error subclass security wrappers', () => {
it('should block __defineGetter__ on TypeError', () => {
expect(() => evaluate('={{TypeError.__defineGetter__}}')).toThrow(
'Cannot access "__defineGetter__" due to security concerns',
);
});
it('should block __defineGetter__ on SyntaxError', () => {
expect(() => evaluate('={{SyntaxError.__defineGetter__}}')).toThrow(
'Cannot access "__defineGetter__" due to security concerns',
);
});
it('should block prepareStackTrace on all error types', () => {
expect(() => evaluate('={{TypeError.prepareStackTrace}}')).toThrow();
expect(() => evaluate('={{SyntaxError.prepareStackTrace}}')).toThrow();
expect(() => evaluate('={{RangeError.prepareStackTrace}}')).toThrow();
expect(() => evaluate('={{ReferenceError.prepareStackTrace}}')).toThrow();
expect(() => evaluate('={{EvalError.prepareStackTrace}}')).toThrow();
expect(() => evaluate('={{URIError.prepareStackTrace}}')).toThrow();
});
it('should allow normal Error subclass functionality', () => {
expect(evaluate('={{new TypeError("test").message}}')).toEqual('test');
expect(evaluate('={{new TypeError("test").name}}')).toEqual('TypeError');
expect(evaluate('={{new SyntaxError("test") instanceof Error}}')).toEqual(true);
});
});
describe('RCE prevention', () => {
it('should block the Object.defineProperty + prepareStackTrace RCE attack', () => {
// This is the actual attack payload that was used
// Attack fails because Object.defineProperty is undefined,
// calling undefined(...) throws TypeError, and the expression returns undefined
const payload = `={{(() => {
Object.defineProperty(Error, 'prepareStackTrace', {
value: (e, stack) => {
try {
const g = stack[0].getThis();
if (!g || !g.global || !g.global.process) return "no_global";
const p = g.global.process;
const gbm = p.getBuiltinModule;
if (!gbm) return "no_gbm";
const cp = gbm('child_process');
return cp.execSync('echo pwned').toString();
} catch (x) {
return "err:" + x.message;
}
},
configurable: true
});
return new Error().stack;
})()}}`;
// Attack is blocked - make sure it throws
expect(() => evaluate(payload)).toThrowError(/due to security concerns/);
});
it('should block __defineGetter__ bypass attack', () => {
// Alternative attack using __defineGetter__ to set prepareStackTrace
// Attack fails because __defineGetter__ is blocked at AST level
const payload = `={{(() => {
Error.__defineGetter__('prepareStackTrace', function() {
return (e, stack) => 'ATTACK_WORKED';
});
return new Error().stack;
})()}}`;
// Attack is blocked at AST parsing level
expect(() => evaluate(payload)).toThrow(
'Cannot access "__defineGetter__" due to security concerns',
);
});
it('should block getOwnPropertyDescriptor bypass attempt', () => {
// Attempt to read blocked properties via getOwnPropertyDescriptor
// getOwnPropertyDescriptor is undefined, calling it throws TypeError
const payload = `={{(() => {
const desc = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace');
return desc ? 'HAS_DESC' : 'NO_DESC';
})()}}`;
// getOwnPropertyDescriptor is undefined, calling undefined() throws
const result = evaluate(payload);
expect(result).toBeUndefined();
});
it('should block indirect access to defineProperty via bracket notation', () => {
expect(evaluate("={{Object['defineProperty']}}")).toBeUndefined();
});
it('should block storing defineProperty in a variable', () => {
// Even if you try to store it, you get undefined
const result = evaluate('={{(() => { const dp = Object.defineProperty; return dp; })()}}');
expect(result).toBeUndefined();
});
it('should block prototype pollution via __lookupGetter__ as bare identifier', () => {
const payload = `={{(() => {
const getProto = __lookupGetter__('__proto__');
const objProto = getProto.call({});
objProto['win'] = 1337;
const empty = {};
return empty['win'];
})()}}`;
// Now blocked at AST level when trying to call __lookupGetter__
expect(() => evaluate(payload)).toThrow(
'Cannot access "__lookupGetter__" due to security concerns',
);
});
it('should block __lookupGetter__ as bare identifier', () => {
expect(() => evaluate('={{__lookupGetter__}}')).toThrow(
'Cannot access "__lookupGetter__" due to security concerns',
);
});
it('should block __lookupSetter__ as bare identifier', () => {
expect(() => evaluate('={{__lookupSetter__}}')).toThrow(
'Cannot access "__lookupSetter__" due to security concerns',
);
});
it('should block __defineGetter__ as bare identifier', () => {
expect(() => evaluate('={{__defineGetter__}}')).toThrow(
'Cannot access "__defineGetter__" due to security concerns',
);
});
it('should block __defineSetter__ as bare identifier', () => {
expect(() => evaluate('={{__defineSetter__}}')).toThrow(
'Cannot access "__defineSetter__" due to security concerns',
);
});
it('should block __lookupGetter__ on object literals', () => {
expect(() => evaluate('={{{}.__lookupGetter__("__proto__")}}')).toThrow(
'Cannot access "__lookupGetter__" due to security concerns',
);
});
it('should block prototype pollution RCE via __lookupGetter__ on object literal', () => {
const payload = `={{(() => {
const getProto = {}.__lookupGetter__("__proto__");
const setProto = getProto.call(new Set());
if (!setProto._has) {
setProto._has = setProto.has;
setProto.has = function (a) {
if (["construct" + "or"].includes(a)) {
return false;
}
try {
return this._has(a);
} catch {
return false;
}
};
}
return setProto;
})()}}`;
expect(() => evaluate(payload)).toThrow(
'Cannot access "__lookupGetter__" due to security concerns',
);
});
it('should block TOCTOU bypass via custom toString()', () => {
const payload = `={{(() => {
function createBypass() {
let value = 'noop';
return {
toString: () => {
const current = value;
value = 'constructor';
return current;
}
}
}
return ({})[createBypass()][createBypass()]('return 1')();
})()}}`;
expect(evaluate(payload)).toBeUndefined();
});
it('should block `__sanitize` override attempt', () => {
const payload = `={{(() => {
__sanitize = a => a;
return this['const' + 'ructor']['const' + 'ructor']('return 1')();
})()}}`;
expect(() => evaluate(payload)).toThrow();
});
const reservedVariablePayloads: Array<[string, string]> = [
[
'`___n8n_data` declaration',
`={{(() => {
const ___n8n_data = {__sanitize: a => a};
return 1;
})()}}`,
],
[
'`__sanitize` declaration',
`={{(() => {
const __sanitize = a => a;
return 1;
})()}}`,
],
[
'array destructuring declaration',
`={{(() => {
const [___n8n_data] = [{ __sanitize: (v) => v }];
return 1;
})()}}`,
],
[
'object destructuring declaration',
`={{(() => {
const {a: ___n8n_data} = { a: { __sanitize: (v) => v } };
return 1;
})()}}`,
],
[
'function parameter identifier',
`={{((___n8n_data) => {
return ___n8n_data;
})({})}}`,
],
[
'function parameter object pattern',
`={{(({a: ___n8n_data}) => {
return ___n8n_data;
})({ a: { __sanitize: (v) => v } })}}`,
],
[
'function parameter array pattern',
`={{(([___n8n_data]) => {
return ___n8n_data;
})([{ __sanitize: (v) => v }])}}`,
],
[
'function parameter default value',
`={{((___n8n_data = { __sanitize: (v) => v }) => {
return ___n8n_data;
})()}}`,
],
[
'function parameter rest element',
`={{((...___n8n_data) => {
return ___n8n_data;
})(1)}}`,
],
[
'function declaration name',
`={{(() => {
function ___n8n_data() {}
return 1;
})()}}`,
],
[
'class declaration name',
`={{(() => {
class ___n8n_data {}
return 1;
})()}}`,
],
[
'catch object pattern parameter',
`={{(() => {
try {
throw { a: { __sanitize: (v) => v } };
} catch ({ a: ___n8n_data }) {
return ___n8n_data;
}
})()}}`,
],
[
'catch array pattern parameter',
`={{(() => {
try {
throw [{ __sanitize: (v) => v }];
} catch ([___n8n_data]) {
return ___n8n_data;
}
})()}}`,
],
[
'for-of object pattern declaration',
`={{(() => {
for (const { a: ___n8n_data } of [{ a: { __sanitize: (v) => v } }]) {
return ___n8n_data;
}
})()}}`,
],
[
'for-of assignment pattern target',
`={{(() => {
for ([___n8n_data] of [[{ __sanitize: (v) => v }]]) {
return ___n8n_data;
}
})()}}`,
],
[
'destructuring assignment target',
`={{(() => {
[___n8n_data] = [{ __sanitize: (v) => v }];
return ___n8n_data;
})()}}`,
],
];
for (const [name, payload] of reservedVariablePayloads) {
it(`should block reserved variable shadowing via ${name}`, () => {
expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError);
});
}
it('should block extend() constructor access on arrow functions', () => {
expect(() => evaluate('={{ extend((() => {}), "constructor", ["return 1"])() }}')).toThrow(
/due to security concerns/,
);
});
it('should block extendOptional() constructor access on arrow functions', () => {
expect(() =>
evaluate('={{ extendOptional((() => {}), "constructor")("return 1")() }}'),
).toThrow(/due to security concerns/);
});
it('should block extend() constructor access on extend itself', () => {
expect(() => evaluate('={{ extend(extend, "constructor", ["return 1"])() }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() constructor access on extendOptional', () => {
expect(() =>
evaluate('={{ extend(extendOptional, "constructor", ["return 1"])() }}'),
).toThrow(/due to security concerns/);
});
it('should block extend() constructor access on isNaN', () => {
expect(() => evaluate('={{ extend(isNaN, "constructor", ["return 1"])() }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() constructor access on parseFloat', () => {
expect(() => evaluate('={{ extend(parseFloat, "constructor", ["return 1"])() }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() __proto__ access', () => {
expect(() => evaluate('={{ extend({}, "__proto__", []) }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() prototype access', () => {
expect(() => evaluate('={{ extend({}, "prototype", []) }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() with custom toString() returning constructor', () => {
expect(() =>
evaluate('={{ extend((() => {}), {toString: () => "constructor"}, ["return 1"])() }}'),
).toThrow(/due to security concerns/);
});
it('should block extend() with custom toString() returning __proto__', () => {
expect(() => evaluate('={{ extend({}, {toString: () => "__proto__"}, []) }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() constructor access on arrow functions', () => {
expect(() => evaluate('={{ extend((() => {}), "constructor", ["return 1"])() }}')).toThrow(
/due to security concerns/,
);
});
it('should block extendOptional() constructor access on arrow functions', () => {
expect(() =>
evaluate('={{ extendOptional((() => {}), "constructor")("return 1")() }}'),
).toThrow(/due to security concerns/);
});
it('should block extend() constructor access on extend itself', () => {
expect(() => evaluate('={{ extend(extend, "constructor", ["return 1"])() }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() constructor access on extendOptional', () => {
expect(() =>
evaluate('={{ extend(extendOptional, "constructor", ["return 1"])() }}'),
).toThrow(/due to security concerns/);
});
it('should block extend() constructor access on isNaN', () => {
expect(() => evaluate('={{ extend(isNaN, "constructor", ["return 1"])() }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() constructor access on parseFloat', () => {
expect(() => evaluate('={{ extend(parseFloat, "constructor", ["return 1"])() }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() __proto__ access', () => {
expect(() => evaluate('={{ extend({}, "__proto__", []) }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() prototype access', () => {
expect(() => evaluate('={{ extend({}, "prototype", []) }}')).toThrow(
/due to security concerns/,
);
});
it('should block extend() with custom toString() returning constructor', () => {
expect(() =>
evaluate('={{ extend((() => {}), {toString: () => "constructor"}, ["return 1"])() }}'),
).toThrow(/due to security concerns/);
});
it('should block extend() with custom toString() returning __proto__', () => {
expect(() => evaluate('={{ extend({}, {toString: () => "__proto__"}, []) }}')).toThrow(
/due to security concerns/,
);
});
});
});
describe('Test all expression value fixtures', () => {
const expression = workflow.expression;
const evaluate = (value: string, data: INodeExecutionData[]) => {
const itemIndex = data.length === 0 ? -1 : 0;
return expression.getParameterValue(value, null, 0, itemIndex, 'node', data, 'manual', {});
};
for (const t of baseFixtures) {
if (!t.tests.some((test) => test.type === 'evaluation')) {
continue;
}
test(t.expression, () => {
vi.spyOn(workflow, 'getParentNodes').mockReturnValue(['Parent']);
const evaluationTests = t.tests.filter(
(test): test is ExpressionTestEvaluation => test.type === 'evaluation',
);
for (const test of evaluationTests) {
const input = test.input.map((d) => ({ json: d })) as any;
if ('error' in test) {
vi.useFakeTimers({ now: test.error.timestamp });
expect(() => evaluate(t.expression, input)).toThrowError(test.error);
vi.useRealTimers();
} else {
expect(evaluate(t.expression, input)).toStrictEqual(test.output);
}
}
});
}
});
describe('Test all expression transform fixtures', () => {
for (const t of baseFixtures) {
if (!t.tests.some((test) => test.type === 'transform')) {
continue;
}
test(t.expression, () => {
vi.useFakeTimers({ now: new Date() });
for (const test of t.tests.filter(
(test): test is ExpressionTestTransform => test.type === 'transform',
)) {
const expr = t.expression;
expect(extendSyntax(expr, test.forceTransform)).toEqual(test.result ?? expr);
}
vi.useRealTimers();
});
}
});
describe('resolveSimpleParameterValue with IWorkflowDataProxyData', () => {
it('should evaluate expression with provided IWorkflowDataProxyData', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
id: 'test',
name: 'Test',
nodes: [
{
id: '1',
name: 'TestNode',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
// Create WorkflowDataProxy to get IWorkflowDataProxyData
const dataProxy = new WorkflowDataProxy(
workflow,
null,
0,
0,
'TestNode',
[{ json: { value: 42 } }],
{},
'manual',
{},
);
const data = dataProxy.getDataProxy();
// Test Expression with new API
const timezone = workflow.settings?.timezone ?? 'UTC';
const expression = new Expression(timezone);
const result = expression.resolveSimpleParameterValue('={{ $json.value * 2 }}', data, false);
expect(result).toBe(84);
});
it('should handle non-expression values', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
id: 'test',
name: 'Test',
nodes: [
{
id: '1',
name: 'TestNode',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
const dataProxy = new WorkflowDataProxy(
workflow,
null,
0,
0,
'TestNode',
[],
{},
'manual',
{},
);
const data = dataProxy.getDataProxy();
const timezone = workflow.settings?.timezone ?? 'UTC';
const expression = new Expression(timezone);
// Non-expression value should be returned as-is
expect(expression.resolveSimpleParameterValue('plain string', data, false)).toBe(
'plain string',
);
expect(expression.resolveSimpleParameterValue(123, data, false)).toBe(123);
expect(expression.resolveSimpleParameterValue(true, data, false)).toBe(true);
});
});
describe('getParameterValue with IWorkflowDataProxyData', () => {
it('should evaluate simple expression with provided IWorkflowDataProxyData', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
id: 'test',
name: 'Test',
nodes: [
{
id: '1',
name: 'TestNode',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
const dataProxy = new WorkflowDataProxy(
workflow,
null,
0,
0,
'TestNode',
[{ json: { text: 'hello' } }],
{},
'manual',
{},
);
const data = dataProxy.getDataProxy();
const timezone = workflow.settings?.timezone ?? 'UTC';
const expression = new Expression(timezone);
const result = expression.resolveSimpleParameterValue(
'={{ $json.text.toUpperCase() }}',
data,
false,
);
expect(result).toBe('HELLO');
});
});
});
@@ -0,0 +1,25 @@
import { isExpression } from '../../src/expressions/expression-helpers';
describe('ExpressionHelpers', () => {
describe('isExpression', () => {
describe('should return true for valid expressions', () => {
test.each([
['=1', 'simple number expression'],
['=true', 'boolean expression'],
['="hello"', 'string expression'],
['={{ $json.field }}', 'complex expression with spaces'],
])('"$s" should be an expression', (expr) => {
expect(isExpression(expr)).toBe(true);
});
});
describe('should return false for invalid expressions', () => {
test.each([[null], [undefined], [1], [true], [''], ['hello']])(
'"$s" should not be an expression',
(expr) => {
expect(isExpression(expr)).toBe(false);
},
);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
{
"data": {
"resultData": {
"runData": {}
}
}
}
@@ -0,0 +1,130 @@
{
"nodes": [
{
"parameters": {
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.8,
"position": [280, -160],
"id": "48d38b5e-d75f-4245-9f6b-c9ab623b1a7a",
"name": "AI Agent"
},
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "914a83be-5bd5-46f9-8b39-1456d12f9429",
"name": "When clicking Execute workflow"
},
{
"parameters": {},
"type": "@n8n/n8n-nodes-langchain.toolCalculator",
"typeVersion": 1,
"position": [780, 60],
"id": "d939bfce-6fbd-4f13-8ba2-91d605bdb81b",
"name": "Calculator"
},
{
"parameters": {},
"type": "@n8n/n8n-nodes-langchain.toolWikipedia",
"typeVersion": 1,
"position": [680, 500],
"id": "efdb00f3-cf60-4c3a-9b18-2523d2fc3177",
"name": "Wikipedia"
},
{
"parameters": {
"calendar": {
"__rl": true,
"mode": "list",
"value": ""
},
"start": "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Start', ``, 'string') }}",
"additionalFields": {}
},
"type": "n8n-nodes-base.googleCalendarTool",
"typeVersion": 1.3,
"position": [440, 60],
"id": "a76e6696-1e19-4aa4-b5d4-c43b332c8bc8",
"name": "Google Calendar"
},
{
"parameters": {
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.8,
"position": [280, 280],
"id": "18a77a68-10dc-486d-b179-6d787371878c",
"name": "Another Agent"
},
{
"parameters": {},
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.3,
"position": [300, 80],
"id": "a0f31ee2-14b1-4ce7-97eb-a070346db0d3",
"name": "Simple Memory"
}
],
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
},
{
"node": "Another Agent",
"type": "main",
"index": 0
}
]
]
},
"Calculator": {
"ai_tool": [[]]
},
"Wikipedia": {
"ai_tool": [
[
{
"node": "Another Agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Google Calendar": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "AI Agent",
"type": "ai_memory",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "866ca65bee13401b1e2b632cdf2767d28ec3301d61bdb4ceabc832d1fe22a83e"
}
}
@@ -0,0 +1,140 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"Start": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": {}
}
]
]
},
"source": []
}
],
"Function": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": { "initialName": 105 },
"pairedItem": { "item": 0 }
},
{
"json": { "initialName": 160 },
"pairedItem": { "item": 0 }
},
{
"json": { "initialName": 121 },
"pairedItem": { "item": 0 }
},
{
"json": { "initialName": 275 },
"pairedItem": { "item": 0 }
},
{
"json": { "initialName": 950 },
"pairedItem": { "item": 0 }
}
]
]
},
"source": [
{
"previousNode": "Start"
}
]
}
],
"Rename": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": { "data": 105 },
"pairedItem": { "item": 0 }
},
{
"json": { "data": 160 },
"pairedItem": { "item": 1 }
},
{
"json": { "data": 121 },
"pairedItem": { "item": 2 }
},
{
"json": { "data": 275 },
"pairedItem": { "item": 3 }
},
{
"json": { "data": 950 },
"pairedItem": { "item": 4 }
}
]
]
},
"source": [
{
"previousNode": "Function"
}
]
}
],
"End": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": { "data": 105 },
"pairedItem": { "item": 0 }
},
{
"json": { "data": 160 },
"pairedItem": { "item": 1 }
},
{
"json": { "data": 121 },
"pairedItem": { "item": 2 }
},
{
"json": { "data": 275 },
"pairedItem": { "item": 3 }
},
{
"json": { "data": 950 },
"pairedItem": { "item": 4 }
}
]
]
},
"source": [
{
"previousNode": "Rename"
}
]
}
]
}
}
},
"mode": "manual",
"startedAt": "2024-02-08T15:45:18.848Z",
"stoppedAt": "2024-02-08T15:45:18.862Z",
"status": "running"
}
@@ -0,0 +1,86 @@
{
"name": "",
"nodes": [
{
"name": "Start",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-1",
"position": [100, 200]
},
{
"name": "Function",
"type": "test.set",
"parameters": {
"functionCode": "// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require(\"luxon\");\n\nconst data = [\n {\n \"length\": 105\n },\n {\n \"length\": 160\n },\n {\n \"length\": 121\n },\n {\n \"length\": 275\n },\n {\n \"length\": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));"
},
"typeVersion": 1,
"id": "uuid-2",
"position": [280, 200]
},
{
"name": "Rename",
"type": "test.set",
"parameters": {
"value1": "data",
"value2": "initialName"
},
"typeVersion": 1,
"id": "uuid-3",
"position": [460, 200]
},
{
"name": "Set",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-4",
"position": [640, 200]
},
{
"name": "End",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-5",
"position": [640, 200]
}
],
"pinData": {},
"connections": {
"Start": {
"main": [
[
{
"node": "Function",
"type": "main",
"index": 0
}
]
]
},
"Function": {
"main": [
[
{
"node": "Rename",
"type": "main",
"index": 0
}
]
]
},
"Rename": {
"main": [
[
{
"node": "End",
"type": "main",
"index": 0
}
]
]
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,747 @@
{
"name": "WorkflowDataProxy errors",
"nodes": [
{
"parameters": {},
"id": "b5122d27-4bb5-4100-a69b-03b1dcac76c7",
"name": "When clicking Execute workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [740, 1680]
},
{
"parameters": {
"operation": "getAllPeople"
},
"id": "bf471582-900d-47af-848c-2d4218798775",
"name": "Customer Datastore (n8n training)",
"type": "n8n-nodes-base.n8nTrainingCustomerDatastore",
"typeVersion": 1,
"position": [1180, 1680]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "name",
"stringValue": "={{ $json.name }}"
}
]
},
"options": {}
},
"id": "1de94b04-c87b-4ef1-b5d7-5078f9e33220",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1400, 1680]
},
{
"parameters": {
"content": "These expression should always be red — there is no way of getting the input data even if you execute. Text should be:",
"height": 349.2762683040461,
"width": 339
},
"id": "c277f7c6-8a7a-41e9-9484-78e90bd205bf",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1020, 1040]
},
{
"parameters": {
"fieldsToAggregate": {
"fieldToAggregate": [
{
"fieldToAggregate": "name"
}
]
},
"options": {}
},
"id": "f6606ff5-4d66-4efb-8dad-de7662f20867",
"name": "Aggregate",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [1820, 860]
},
{
"parameters": {
"content": "This error should be\n\n[Can't determine which item to use]",
"height": 255,
"width": 177
},
"id": "71fbae4a-f5b3-4db1-9684-83c4d2037099",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 760]
},
{
"parameters": {
"content": "[No path back to node]",
"height": 209,
"width": 150
},
"id": "24e878cb-a681-4c00-bec1-83188aa20eb7",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1020, 1132]
},
{
"parameters": {
"content": "[No input connected]",
"height": 201,
"width": 150
},
"id": "4bd26f55-87b5-4ad1-b3f1-ae2786941114",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1200, 1132]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"field\": \"the same\"\n }\n];"
},
"id": "6538818e-c5b3-422b-920c-d5d52533578b",
"name": "Break pairedItem chain",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 1120]
},
{
"parameters": {
"content": "This error should be\n\n[Can't determine which item to use]",
"height": 255,
"width": 177
},
"id": "42641e54-60e1-46d7-bcb4-b55a83f89f6b",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 1020]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"json\": {\n \"field\": \"the same\"\n },\n \"pairedItem\": 99\n }\n];"
},
"id": "05583883-ab4a-42c2-9edb-8e8cf3c9d074",
"name": "Incorrect pairedItem info",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 1680]
},
{
"parameters": {
"content": "This error should be\n\n[Can't determine which item to use]",
"height": 255,
"width": 177
},
"id": "aea58e9e-5a00-4a86-a0bc-b077a07cd1f4",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 1580]
},
{
"parameters": {
"content": "If the pinned node is executed, make grey and use text:\n[For preview, unpin node <node_name> and execute]",
"height": 255,
"width": 237.63786881219818
},
"id": "3fdf6bdc-8065-421b-9ecf-6453946356a4",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 1840]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"json\": {\n \"field\": \"the same\"\n },\n \"pairedItem\": [1, 2, 3, 4]\n }\n];"
},
"id": "f8de7b0a-79c1-4b7a-a183-feb94f2f8625",
"name": "Multiple matching items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 2200]
},
{
"parameters": {
"content": "This error should be\n\n[Can't determine which item to use]",
"height": 255,
"width": 177
},
"id": "601c050a-7909-4708-be8d-4de248b68392",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 2100]
},
{
"parameters": {
"content": "This should be grey, with text\n\n[For preview, unpin node <node_name> and execute]",
"height": 291.70186796527776,
"width": 177
},
"id": "dfdcfaf4-a76b-4307-97a6-3fd7772e9fa8",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 2360]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"json\": {\n \"field\": \"the same\"\n },\n \"pairedItem\": [1, 2, 3, 4]\n }\n];"
},
"id": "8f2a9642-68e7-4dc6-a6c2-2018919327a3",
"name": "Multiple matching items, pinned",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 2500]
},
{
"parameters": {
"content": "If the pinned node isn't executed (e.g. if you execute one of the other code nodes in the same column), the expression is green!",
"height": 128.93706220621976,
"width": 177
},
"id": "65cf9b4c-a96d-46f5-b9bb-f6d88d1fbc44",
"name": "Sticky Note9",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2220, 1940]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"json\": {\n \"field\": \"the same\"\n },\n \"pairedItem\": 99\n }\n];"
},
"id": "0bdfe0d2-7de2-472d-bc0a-2d0eff0e08c7",
"name": "Incorrect pairedItem info, pinned1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 1940]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"field\": \"the same\"\n }\n];"
},
"id": "b080a98e-d983-414a-b925-bdfc7ab2c3b6",
"name": "Break pairedItem chain, pinned",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 1420]
},
{
"parameters": {
"content": "This should be grey, with text\n\n[For preview, unpin node <node_name> and execute]",
"height": 291.70186796527776,
"width": 177
},
"id": "ce083193-1944-4c6c-925d-9e23c5194d98",
"name": "Sticky Note11",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 1280]
},
{
"parameters": {
"content": "We should also change the output pane error on execution in this case.\n\nERROR: No path back to '<node_name>' node\nDescription: Please make sure it is connected to this node (there can be other nodes in between)",
"height": 209,
"width": 301.59467203049536
},
"id": "755e07f0-3f18-4b08-ad30-79221a76507a",
"name": "Sticky Note10",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1080, 1360]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "1fff886f-3d13-4fbf-b0fb-7e2f845937c0",
"leftValue": "={{ false }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "56dd65f0-d67a-42ce-a876-77434f621dc3",
"name": "Impossible if",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1000, 2000]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "test",
"stringValue": "xzy"
}
]
},
"options": {}
},
"id": "11eadfc8-d14d-407c-b6d5-6e59b2e427a1",
"name": "Impossible",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1180, 1980]
},
{
"parameters": {
"content": "Should be an error when using .item:\n\n[No path back to node]",
"height": 237.7232010163043,
"width": 150
},
"id": "c3a3fdc2-66fa-4562-a359-45bdece2f625",
"name": "Sticky Note12",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1400, 1880]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "name",
"stringValue": "={{ $('Impossible').item.json.name }}"
}
]
},
"options": {}
},
"id": "4cbbee96-dd4c-4625-95b9-c68faef3e9a8",
"name": "Reference impossible with .item",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1420, 2000]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "name",
"stringValue": "={{ $('Impossible').first().json.name }}"
}
]
},
"options": {}
},
"id": "6d47bd08-810a-4ade-be57-635adc1df47f",
"name": "Reference impossible with .first()",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1420, 2320]
},
{
"parameters": {
"content": "When using .first(), .last() or .all() and the node isn't executed, show grey warning:\n\n[Execute <node_name> for preview]",
"height": 330.27573762439613,
"width": 229.78666948973432
},
"id": "1fcf2562-0789-41ad-8c92-44bcdd5d44e6",
"name": "Sticky Note13",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1400, 2180]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('non existent') }}"
}
]
},
"options": {}
},
"id": "327d7f7b-61a5-4d60-9542-d61f84e7c83a",
"name": "Reference non-existent node",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1000, 2320]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Customer Datastore (n8n training)').item.json.email }}"
}
]
},
"options": {}
},
"id": "38e3a736-4e13-4c23-af16-e50e605c4fb5",
"name": "NoPathBack",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1040, 1184]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $json.email }}"
}
]
},
"options": {}
},
"id": "2a7eaf81-6d64-488d-baf6-cc2f962908af",
"name": "NoInputConnection",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1220, 1180]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "166ee813-1db8-43a6-ace4-990c41dfeaea",
"name": "PairedItemInfoMissing",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 1120]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "a2dca54c-03ef-4a16-bf29-71eb0012cf0b",
"name": "PairedItemInfoMissingPinned",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 1420]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "0a1f566b-8dcf-4e28-81c4-faeadcdc02fb",
"name": "IncorrectPairedItem",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 1680]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.to }}"
}
]
},
"options": {}
},
"id": "4d76b75f-5896-48ba-bb2f-8a2574ec1b8b",
"name": "IncorrectPairedItemPinned",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 1940]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "c4636b5c-c13a-441b-a59c-23962b2757b3",
"name": "PairedItemMultipleMatches2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 2200]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "6d687cf8-5309-4d44-aab3-aa023a42fa27",
"name": "PairedItemMultipleMatches",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 860]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "d87a7aa4-b4c7-4fad-897d-a7ce0657bef3",
"name": "IncorrectPairedItemPinned2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 2500]
}
],
"pinData": {
"Multiple matching items, pinned": [
{
"json": {
"field": "the same"
}
}
],
"Incorrect pairedItem info, pinned1": [
{
"json": {
"field": "the same"
}
}
],
"Break pairedItem chain, pinned": [
{
"json": {
"field": "the same"
}
}
]
},
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "Customer Datastore (n8n training)",
"type": "main",
"index": 0
},
{
"node": "Impossible if",
"type": "main",
"index": 0
},
{
"node": "Reference non-existent node",
"type": "main",
"index": 0
}
]
]
},
"Customer Datastore (n8n training)": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
},
{
"node": "Reference impossible with .item",
"type": "main",
"index": 0
},
{
"node": "Reference impossible with .first()",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "Aggregate",
"type": "main",
"index": 0
},
{
"node": "Break pairedItem chain",
"type": "main",
"index": 0
},
{
"node": "Incorrect pairedItem info",
"type": "main",
"index": 0
},
{
"node": "Multiple matching items",
"type": "main",
"index": 0
},
{
"node": "Incorrect pairedItem info, pinned1",
"type": "main",
"index": 0
},
{
"node": "Multiple matching items, pinned",
"type": "main",
"index": 0
},
{
"node": "Break pairedItem chain, pinned",
"type": "main",
"index": 0
}
]
]
},
"Aggregate": {
"main": [
[
{
"node": "PairedItemMultipleMatches",
"type": "main",
"index": 0
}
]
]
},
"Break pairedItem chain": {
"main": [
[
{
"node": "PairedItemInfoMissing",
"type": "main",
"index": 0
}
]
]
},
"Incorrect pairedItem info": {
"main": [
[
{
"node": "IncorrectPairedItem",
"type": "main",
"index": 0
}
]
]
},
"Multiple matching items": {
"main": [
[
{
"node": "PairedItemMultipleMatches2",
"type": "main",
"index": 0
}
]
]
},
"Multiple matching items, pinned": {
"main": [
[
{
"node": "IncorrectPairedItemPinned2",
"type": "main",
"index": 0
}
]
]
},
"Incorrect pairedItem info, pinned1": {
"main": [
[
{
"node": "IncorrectPairedItemPinned",
"type": "main",
"index": 0
}
]
]
},
"Break pairedItem chain, pinned": {
"main": [
[
{
"node": "PairedItemInfoMissingPinned",
"type": "main",
"index": 0
}
]
]
},
"Impossible if": {
"main": [
[
{
"node": "Impossible",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "f6276c80-c1d1-485b-9d07-894868bcd701",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "2e88d456a76a9edc44cbcda082bb44ddef9555356ef691b0c6a45099d5095a45"
},
"id": "BmXv9neCtTggKXuG",
"tags": []
}
@@ -0,0 +1,221 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"When clicking Execute workflow": [
{
"hints": [],
"startTime": 1733478795595,
"executionTime": 0,
"source": [],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {},
"pairedItem": {
"item": 0
}
}
]
]
}
}
],
"Code": [
{
"hints": [
{
"message": "To make sure expressions after this node work, return the input items that produced each output item. <a target=\"_blank\" href=\"https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/\">More info</a>",
"location": "outputPane"
}
],
"startTime": 1733478795595,
"executionTime": 2,
"source": [
{
"previousNode": "When clicking Execute workflow"
}
],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {
"full_name": "Mr. Input 1",
"email": "input1@n8n.io"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"full_name": "Mr. Input 2",
"email": "input2@n8n.io"
},
"pairedItem": {
"item": 0
}
}
]
]
}
}
],
"Google Sheets1": [
{
"startTime": 1733478796468,
"executionTime": 1417,
"executionStatus": "success",
"source": [null],
"data": {
"ai_tool": [
[
{
"json": {
"response": [
{
"full name": "Mr. Input 1",
"email": "input1@n8n.io"
},
{},
{}
]
}
}
]
]
},
"inputOverride": {
"ai_tool": [
[
{
"json": {
"full_name": "Mr. Input 1",
"email": "input1@n8n.io"
}
}
]
]
},
"metadata": {
"subRun": [
{
"node": "Google Sheets1",
"runIndex": 0
},
{
"node": "Google Sheets1",
"runIndex": 1
}
]
}
},
{
"startTime": 1733478799915,
"executionTime": 1271,
"executionStatus": "success",
"source": [null],
"data": {
"ai_tool": [
[
{
"json": {
"response": [
{
"full name": "Mr. Input 1",
"email": "input1@n8n.io"
},
{},
{}
]
}
}
]
]
},
"inputOverride": {
"ai_tool": [
[
{
"json": {
"full_name": "Mr. Input 2",
"email": "input2@n8n.io"
}
}
]
]
}
}
],
"Agent single list with multiple tool calls": [
{
"hints": [],
"startTime": 1733478795597,
"executionTime": 9157,
"source": [
{
"previousNode": "Code"
}
],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {
"output": "The user \"Mr. Input 1\" with the email \"input1@n8n.io\" has been successfully added to your Users sheet."
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"output": "The user \"Mr. Input 2\" with the email \"input2@n8n.io\" has been successfully added to your Users sheet."
},
"pairedItem": {
"item": 1
}
}
]
]
}
}
]
},
"pinData": {},
"lastNodeExecuted": "Agent single list with multiple tool calls"
},
"executionData": {
"contextData": {},
"nodeExecutionStack": [],
"metadata": {
"Google Sheets1": [
{
"subRun": [
{
"node": "Google Sheets1",
"runIndex": 0
},
{
"node": "Google Sheets1",
"runIndex": 1
}
]
}
]
},
"waitingExecution": {},
"waitingExecutionSource": {}
}
},
"mode": "manual",
"startedAt": "2024-02-08T15:45:18.848Z",
"stoppedAt": "2024-02-08T15:45:18.862Z",
"status": "running"
}
@@ -0,0 +1,114 @@
{
"id": "8d7lUG8IdEyvIUim",
"name": "Multiple items tool",
"active": false,
"nodes": [
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "return [\n { \"full_name\": \"Mr. Input 1\", \"email\": \"input1@n8n.io\" }, \n { \"full_name\": \"Mr. Input 2\", \"email\": \"input2@n8n.io\" }\n]",
"notice": ""
},
"id": "cb19a188-12ae-4d46-86df-4a2044ec3346",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-160, 480]
},
{
"parameters": { "notice": "", "model": "gpt-4o-mini", "options": {} },
"id": "c448b6b4-9e11-4044-96e5-f4138534ae52",
"name": "OpenAI Chat Model1",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1,
"position": [40, 700]
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Add row to Users sheet",
"authentication": "oAuth2",
"resource": "sheet",
"operation": "append",
"columns": {
"mappingMode": "defineBelow",
"value": {
"full name": "={{ $fromAI('full_name') }}",
"email": "={{ $fromAI('email') }}"
},
"matchingColumns": [],
"schema": [
{
"id": "full name",
"displayName": "full name",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "email",
"displayName": "email",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
}
]
},
"options": { "useAppend": true }
},
"id": "d8b40267-9397-45b6-8a64-ee7e8f9eb8a8",
"name": "Google Sheets1",
"type": "n8n-nodes-base.googleSheetsTool",
"typeVersion": 4.5,
"position": [240, 700]
},
{
"parameters": {
"aiAgentStarterCallout": "",
"agent": "toolsAgent",
"promptType": "define",
"text": "=Add this user to my Users sheet:\n{{ $json.toJsonString() }}",
"hasOutputParser": false,
"options": {},
"credentials": ""
},
"id": "0d6c1bd7-cc91-4571-8fdb-c875a1af44c7",
"name": "Agent single list with multiple tool calls",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.7,
"position": [40, 480]
}
],
"connections": {
"When clicking Execute workflow": {
"main": [[{ "node": "Code", "type": "main", "index": 0 }]]
},
"Code": {
"main": [
[{ "node": "Agent single list with multiple tool calls", "type": "main", "index": 0 }]
]
},
"OpenAI Chat Model1": {
"ai_languageModel": [
[
{
"node": "Agent single list with multiple tool calls",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Google Sheets1": {
"ai_tool": [
[{ "node": "Agent single list with multiple tool calls", "type": "ai_tool", "index": 0 }]
]
}
},
"pinData": {}
}
@@ -0,0 +1,215 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"When clicking Execute workflow": [
{
"hints": [],
"startTime": 1733478795595,
"executionTime": 0,
"source": [],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {},
"pairedItem": {
"item": 0
}
}
]
]
}
}
],
"Code": [
{
"hints": [
{
"message": "To make sure expressions after this node work, return the input items that produced each output item. <a target=\"_blank\" href=\"https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/\">More info</a>",
"location": "outputPane"
}
],
"startTime": 1733478795595,
"executionTime": 2,
"source": [
{
"previousNode": "When clicking Execute workflow"
}
],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {
"tool": "Tool 1"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"tool": "Tool 2"
},
"pairedItem": {
"item": 0
}
}
]
]
}
}
],
"Google Sheets1": [
{
"startTime": 1733478796468,
"executionTime": 1417,
"executionStatus": "success",
"source": [null],
"data": {
"ai_tool": [
[
{
"json": {
"response": [
{
"full name": "Mr. Input 1"
},
{},
{}
]
}
}
]
]
},
"inputOverride": {
"ai_tool": [
[
{
"json": {
"tool": "Tool 1"
}
}
]
]
},
"metadata": {
"subRun": [
{
"node": "Google Sheets1",
"runIndex": 0
},
{
"node": "Google Sheets1",
"runIndex": 1
}
]
}
},
{
"startTime": 1733478799915,
"executionTime": 1271,
"executionStatus": "success",
"source": [null],
"data": {
"ai_tool": [
[
{
"json": {
"response": [
{
"full name": "Mr. Input 1"
},
{},
{}
]
}
}
]
]
},
"inputOverride": {
"ai_tool": [
[
{
"json": {
"tool": "Tool 2"
}
}
]
]
}
}
],
"Agent single list with multiple tool calls": [
{
"hints": [],
"startTime": 1733478795597,
"executionTime": 9157,
"source": [
{
"previousNode": "Code"
}
],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {
"output": "The user \"Mr. Input 1\" with the email \"input1@n8n.io\" has been successfully added to your Users sheet."
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"output": "The user \"Mr. Input 2\" with the email \"input2@n8n.io\" has been successfully added to your Users sheet."
},
"pairedItem": {
"item": 1
}
}
]
]
}
}
]
},
"pinData": {},
"lastNodeExecuted": "Agent single list with multiple tool calls"
},
"executionData": {
"contextData": {},
"nodeExecutionStack": [],
"metadata": {
"Google Sheets1": [
{
"subRun": [
{
"node": "Google Sheets1",
"runIndex": 0
},
{
"node": "Google Sheets1",
"runIndex": 1
}
]
}
]
},
"waitingExecution": {},
"waitingExecutionSource": {}
}
},
"mode": "manual",
"startedAt": "2024-02-08T15:45:18.848Z",
"stoppedAt": "2024-02-08T15:45:18.862Z",
"status": "running"
}
@@ -0,0 +1,104 @@
{
"id": "8d7lUG8IdEyvIUim",
"name": "Multiple items tool",
"active": false,
"nodes": [
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "return [\n { \"tool\": \"Tool 1\" }, \n { \"tool\": \"Tool 2\" }\n]",
"notice": ""
},
"id": "cb19a188-12ae-4d46-86df-4a2044ec3346",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-160, 480]
},
{
"parameters": { "notice": "", "model": "gpt-4o-mini", "options": {} },
"id": "c448b6b4-9e11-4044-96e5-f4138534ae52",
"name": "OpenAI Chat Model1",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1,
"position": [40, 700]
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Add row to Users sheet",
"authentication": "oAuth2",
"resource": "sheet",
"operation": "append",
"columns": {
"mappingMode": "defineBelow",
"value": {
"tool": "={{ $fromAI('tool') }}"
},
"matchingColumns": [],
"schema": [
{
"id": "tool",
"displayName": "tool",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
}
]
},
"options": { "useAppend": true }
},
"id": "d8b40267-9397-45b6-8a64-ee7e8f9eb8a8",
"name": "Google Sheets1",
"type": "n8n-nodes-base.googleSheetsTool",
"typeVersion": 4.5,
"position": [240, 700]
},
{
"parameters": {
"aiAgentStarterCallout": "",
"agent": "toolsAgent",
"promptType": "define",
"text": "=Lorem ipsum",
"hasOutputParser": false,
"options": {},
"credentials": ""
},
"id": "0d6c1bd7-cc91-4571-8fdb-c875a1af44c7",
"name": "Agent single list with multiple tool calls",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.7,
"position": [40, 480]
}
],
"connections": {
"When clicking Execute workflow": {
"main": [[{ "node": "Code", "type": "main", "index": 0 }]]
},
"Code": {
"main": [
[{ "node": "Agent single list with multiple tool calls", "type": "main", "index": 0 }]
]
},
"OpenAI Chat Model1": {
"ai_languageModel": [
[
{
"node": "Agent single list with multiple tool calls",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Google Sheets1": {
"ai_tool": [
[{ "node": "Agent single list with multiple tool calls", "type": "ai_tool", "index": 0 }]
]
}
},
"pinData": {}
}
@@ -0,0 +1,62 @@
{
"data": {
"resultData": {
"runData": {
"When clicking 'Execute Workflow'": [
{
"startTime": 1234567890000,
"executionTime": 0,
"data": {
"main": [
[
{
"json": {}
}
]
]
},
"source": []
}
],
"Edit Fields1": [
{
"startTime": 1234567890000,
"executionTime": 0,
"data": {
"main": [
[
{
"json": {
"sessionId": "test-session-123"
}
}
]
]
},
"source": []
}
],
"Memory Node": [
{
"startTime": 1234567890001,
"executionTime": 0,
"data": {
"main": [
[
{
"json": {}
}
]
]
},
"source": [
{
"previousNode": "Edit Fields1"
}
]
}
]
}
}
}
}
@@ -0,0 +1,199 @@
{
"name": "My workflow 16",
"nodes": [
{
"parameters": {
"promptType": "define",
"text": "Greet the user",
"options": {
"maxIterations": 1
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3,
"position": [416, 0],
"id": "c26a647a-58b2-4c10-9cc5-ac3ccb6c6219",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-4.1-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [288, 208],
"id": "4568addd-cc56-4e40-b462-e8c55383ff92",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "Zak03cqeLUOsgkFI",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"inputSource": "passthrough"
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [112, -352],
"id": "1c26bc07-8091-44ba-98b4-e93020bedb91",
"name": "When Executed by Another Workflow"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "ce088643-09fc-4cf0-a04a-a82bb973b8c8",
"name": "response",
"value": "banana",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [320, -352],
"id": "77b084e4-68bb-45c2-ae2f-76a67c859bcc",
"name": "Edit Fields"
},
{
"parameters": {
"text": "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Prompt__User_Message_', ``, 'string') }}",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.agentTool",
"typeVersion": 2.2,
"position": [992, 160],
"id": "2b964f5c-46c7-4e6f-85c2-a3134b3bd223",
"name": "AI Agent Tool"
},
{
"parameters": {},
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.3,
"position": [560, 320],
"id": "6eebeae8-5a25-4a79-8d6d-3fa2cb3fcfa5",
"name": "Simple Memory"
},
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-144, 0],
"id": "e1570d09-e91c-49a2-bd62-24ceb060ecd1",
"name": "When clicking Execute workflow"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "b4fca0ed-9032-43ad-8e10-8ca932c9f1a5",
"name": "sessionId",
"value": "1",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [64, 0],
"id": "4d04fe47-6af4-4440-b782-e9d8163f4cfa",
"name": "Edit Fields1"
}
],
"pinData": {},
"connections": {
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
},
{
"node": "AI Agent Tool",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"When Executed by Another Workflow": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"AI Agent Tool": {
"ai_tool": [[]]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "AI Agent",
"type": "ai_memory",
"index": 0
},
{
"node": "AI Agent Tool",
"type": "ai_memory",
"index": 0
}
]
]
},
"When clicking Execute workflow": {
"main": [
[
{
"node": "Edit Fields1",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields1": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "6c8d6680-cdd4-4ed0-b562-16d3b1d7d915",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
},
"id": "exPkwOsVPRUPnV5V",
"tags": []
}
@@ -0,0 +1,114 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"Manual Trigger": [
{
"startTime": 1749486952181,
"executionIndex": 0,
"source": [],
"hints": [],
"executionTime": 2,
"executionStatus": "success",
"data": { "main": [[{ "json": {}, "pairedItem": { "item": 0 } }]] }
}
],
"Set main variable": [
{
"startTime": 1749486952183,
"executionIndex": 1,
"source": [{ "previousNode": "Manual Trigger" }],
"hints": [],
"executionTime": 2,
"executionStatus": "success",
"data": { "main": [[{ "json": { "main_variable": 2 }, "pairedItem": { "item": 0 } }]] }
}
],
"Set variable_1": [
{
"startTime": 1749486952185,
"executionIndex": 2,
"source": [{ "previousNode": "Set main variable" }],
"hints": [],
"executionTime": 0,
"executionStatus": "success",
"data": {
"main": [[{ "json": { "variable_1": "1234" }, "pairedItem": { "item": 0 } }]]
}
}
],
"Set variable_2": [
{
"startTime": 1749486952186,
"executionIndex": 3,
"source": [{ "previousNode": "Set main variable" }],
"hints": [],
"executionTime": 0,
"executionStatus": "success",
"data": {
"main": [[{ "json": { "variable_2": "2345" }, "pairedItem": { "item": 0 } }]]
}
}
],
"Set variable_3": [
{
"startTime": 1749486952187,
"executionIndex": 4,
"source": [{ "previousNode": "Set main variable" }],
"hints": [],
"executionTime": 0,
"executionStatus": "success",
"data": {
"main": [[{ "json": { "variable_3": "3456" }, "pairedItem": { "item": 0 } }]]
}
}
],
"Merge": [
{
"startTime": 1749486952197,
"executionIndex": 5,
"source": [null, null, { "previousNode": "Set variable_3" }],
"hints": [],
"executionTime": 12,
"executionStatus": "success",
"data": {
"main": [
[{ "json": { "variable_3": "3456" }, "pairedItem": { "item": 0, "input": 2 } }]
]
}
}
],
"Output": [
{
"startTime": 1749486952210,
"executionIndex": 6,
"source": [{ "previousNode": "Merge" }],
"hints": [],
"executionTime": 4,
"executionStatus": "success",
"data": {
"main": [
[
{
"json": { "final_variable_2": "3456", "main": "2" },
"pairedItem": { "item": 0 }
}
]
]
}
}
]
},
"pinData": {},
"lastNodeExecuted": "Output"
},
"executionData": {
"contextData": {},
"nodeExecutionStack": [],
"metadata": {},
"waitingExecution": {},
"waitingExecutionSource": {}
}
}
}
@@ -0,0 +1,203 @@
{
"name": "Paired item",
"nodes": [
{
"parameters": {
"numberInputs": 3
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.1,
"position": [560, 300],
"id": "2b68168b-1494-4c4b-b416-b4fb6bb0afd8",
"name": "Merge",
"alwaysOutputData": true
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "63830a30-a4cc-4a66-9d01-8f0a058d4d43",
"name": "variable_1",
"value": "1234",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [200, 100],
"id": "a1d151cc-8f44-43c6-962f-baecb879d33c",
"name": "Set variable_1"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "63830a30-a4cc-4a66-9d01-8f0a058d4d43",
"name": "variable_2",
"value": "2345",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [200, 300],
"id": "404ff876-b524-4873-8dc0-36664639907a",
"name": "Set variable_2"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "63830a30-a4cc-4a66-9d01-8f0a058d4d43",
"name": "variable_3",
"value": "3456",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [200, 500],
"id": "d403e979-7651-4acf-8e08-1d342b7abe7f",
"name": "Set variable_3"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "89862e7d-0c44-4d6a-897e-a249c06f6346",
"name": "final_variable_2",
"value": "={{ $('Set variable_3').item.json.variable_3 }}",
"type": "string"
},
{
"id": "060e841c-c236-41ae-9396-e23566825f47",
"name": "main",
"value": "={{ $('Set main variable').item.json.main_variable }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [840, 300],
"id": "16514f79-e309-465c-b571-7d81e268d7f0",
"name": "Output"
},
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-240, 300],
"id": "3a8a9543-567c-443c-8742-fa0a8d9fb2e7",
"name": "Manual Trigger"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "6bb9d060-adee-429f-884d-5009ab1a1811",
"name": "main_variable",
"value": 2,
"type": "number"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [-20, 300],
"id": "b6ec9c0f-de04-45d3-a402-3969251a6914",
"name": "Set main variable"
}
],
"pinData": {},
"connections": {
"Merge": {
"main": [
[
{
"node": "Output",
"type": "main",
"index": 0
}
]
]
},
"Set variable_1": {
"main": [[]]
},
"Set variable_2": {
"main": [[]]
},
"Set variable_3": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 2
}
]
]
},
"Manual Trigger": {
"main": [
[
{
"node": "Set main variable",
"type": "main",
"index": 0
}
]
]
},
"Set main variable": {
"main": [
[
{
"node": "Set variable_2",
"type": "main",
"index": 0
},
{
"node": "Set variable_1",
"type": "main",
"index": 0
},
{
"node": "Set variable_3",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "5678abcc-b267-4b5b-ba1b-5f7bb1b085ec",
"meta": {
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
},
"id": "TakJu1jBtMGTFXEA",
"tags": []
}
@@ -0,0 +1,84 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"When clicking Execute workflow": [
{
"hints": [],
"startTime": 1718369813697,
"executionTime": 0,
"source": [],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {
"name": "First item",
"code": 1
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"name": "Second item",
"code": 2
},
"pairedItem": {
"item": 0
}
}
]
]
}
}
],
"If": [
{
"hints": [],
"startTime": 1718369813698,
"executionTime": 1,
"source": [
{
"previousNode": "When clicking Execute workflow"
}
],
"executionStatus": "success",
"data": {
"main": [
[],
[
{
"json": {
"name": "First item",
"code": 1
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"name": "Second item",
"code": 2
},
"pairedItem": {
"item": 1
}
}
]
]
}
}
]
}
}
},
"mode": "manual",
"startedAt": "2024-02-08T15:45:18.848Z",
"stoppedAt": "2024-02-08T15:45:18.862Z",
"status": "running"
}
@@ -0,0 +1,84 @@
{
"meta": {
"instanceId": "060d2be233778dc6349e0f3fa8d972652e8dff467638325ffc56812c6b66ef1a"
},
"nodes": [
{
"parameters": {},
"id": "656d6d8d-1af6-4af7-ab53-7c4e495ee51c",
"name": "When clicking Execute workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-300, 1880]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "1fff886f-3d13-4fbf-b0fb-7e2f845937c0",
"leftValue": "={{ false }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "204feab7-f5e9-458f-8fdb-4b762b184147",
"name": "If",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [40, 1880]
},
{
"parameters": {
"assignments": {
"assignments": []
},
"options": {}
},
"id": "ab122060-f8da-475e-b6c6-d9e486289e1f",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [360, 2080]
}
],
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[],
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}
@@ -0,0 +1,71 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"Start": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": {}
}
]
]
},
"source": []
}
],
"Function": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [[]]
},
"source": [
{
"previousNode": "Start"
}
]
}
],
"Rename": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [[]]
},
"source": [
{
"previousNode": "Function"
}
]
}
],
"End": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [[]]
},
"source": [
{
"previousNode": "Rename"
}
]
}
]
}
}
},
"mode": "manual",
"startedAt": "2024-02-08T15:45:18.848Z",
"stoppedAt": "2024-02-08T15:45:18.862Z",
"status": "running"
}
@@ -0,0 +1,86 @@
{
"name": "",
"nodes": [
{
"name": "Start",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-1",
"position": [100, 200]
},
{
"name": "Function",
"type": "test.set",
"parameters": {
"functionCode": "// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require(\"luxon\");\n\nconst data = [\n {\n \"length\": 105\n },\n {\n \"length\": 160\n },\n {\n \"length\": 121\n },\n {\n \"length\": 275\n },\n {\n \"length\": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));"
},
"typeVersion": 1,
"id": "uuid-2",
"position": [280, 200]
},
{
"name": "Rename",
"type": "test.set",
"parameters": {
"value1": "data",
"value2": "initialName"
},
"typeVersion": 1,
"id": "uuid-3",
"position": [460, 200]
},
{
"name": "Set",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-4",
"position": [640, 200]
},
{
"name": "End",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-5",
"position": [640, 200]
}
],
"pinData": {},
"connections": {
"Start": {
"main": [
[
{
"node": "Function",
"type": "main",
"index": 0
}
]
]
},
"Function": {
"main": [
[
{
"node": "Rename",
"type": "main",
"index": 0
}
]
]
},
"Rename": {
"main": [
[
{
"node": "End",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,216 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"When clicking Execute workflow": [
{
"hints": [],
"startTime": 1737031584297,
"executionTime": 1,
"source": [],
"executionStatus": "success",
"data": { "main": [[{ "json": {}, "pairedItem": { "item": 0 } }]] }
}
],
"DebugHelper": [
{
"hints": [],
"startTime": 1737031584299,
"executionTime": 1,
"source": [{ "previousNode": "When clicking Execute workflow" }],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {
"uid": "3c54ac5d-5c75-409d-9975-76ee151e5fc9",
"email": "Troy.Volkman@gmail.com",
"firstname": "Betty",
"lastname": "Wolf",
"password": "c~837Nv"
},
"pairedItem": { "item": 0 }
},
{
"json": {
"uid": "5b0d9bd7-2ecf-47fb-b484-3eb1e76fa901",
"email": "Martha.Moore@hotmail.com",
"firstname": "Shannon",
"lastname": "Champlin",
"password": "48FF,6dnx"
},
"pairedItem": { "item": 0 }
},
{
"json": {
"uid": "76437ebe-d406-447a-ab89-3b10f5183480",
"email": "Wanda_Witting@hotmail.com",
"firstname": "Alma",
"lastname": "Conn",
"password": "6$G2R3nT"
},
"pairedItem": { "item": 0 }
}
]
]
}
}
],
"Edit Fields": [
{
"hints": [],
"startTime": 1737031584301,
"executionTime": 0,
"source": [{ "previousNode": "DebugHelper" }],
"executionStatus": "success",
"data": {
"main": [
[
{
"json": {
"uid": "d42c6385-12f2-4486-92b5-eebd2e95d161",
"email": "Joanna_Willms@yahoo.com",
"firstname": "Laurie",
"lastname": "Krajcik",
"password": "k%Y2I9oq",
"test": "1"
}
},
{
"json": {
"uid": "53fc09df-5463-4f48-9fda-6500b1b77c82",
"email": "Elaine_Feeney@gmail.com",
"firstname": "Tracy",
"lastname": "Mraz",
"password": "t48s3-r",
"test": "1"
}
}
]
]
}
}
],
"Set": [
{
"hints": [],
"startTime": 1737031584301,
"executionTime": 0,
"source": [{ "previousNode": "Edit Fields" }],
"data": {
"main": [
[
{
"json": {
"uid": "d42c6385-12f2-4486-92b5-eebd2e95d161",
"email": "Joanna_Willms@yahoo.com",
"firstname": "Laurie",
"lastname": "Krajcik",
"password": "k%Y2I9oq",
"test": "1"
},
"pairedItem": {
"item": 0
}
},
{
"json": {
"uid": "53fc09df-5463-4f48-9fda-6500b1b77c82",
"email": "Elaine_Feeney@gmail.com",
"firstname": "Tracy",
"lastname": "Mraz",
"password": "t48s3-r",
"test": "1"
},
"pairedItem": {
"item": 1
}
}
]
]
}
}
]
},
"pinData": {
"Edit Fields": [
{
"json": {
"uid": "d42c6385-12f2-4486-92b5-eebd2e95d161",
"email": "Joanna_Willms@yahoo.com",
"firstname": "Laurie",
"lastname": "Krajcik",
"password": "k%Y2I9oq",
"test": "1"
}
},
{
"json": {
"uid": "53fc09df-5463-4f48-9fda-6500b1b77c82",
"email": "Elaine_Feeney@gmail.com",
"firstname": "Tracy",
"lastname": "Mraz",
"password": "t48s3-r",
"test": "1"
}
}
]
},
"lastNodeExecuted": "Set"
},
"executionData": {
"contextData": {},
"nodeExecutionStack": [
{
"node": {
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "for (let i = 0; i < $input.all().length; i++) { $(\"DebugHelper\").itemMatching(i) } return []",
"notice": ""
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [720, 0],
"id": "d4e8b6a2-cd73-452d-b5f0-986753f5dc4a",
"name": "Set"
},
"data": {
"main": [
[
{
"json": {
"uid": "d42c6385-12f2-4486-92b5-eebd2e95d161",
"email": "Joanna_Willms@yahoo.com",
"firstname": "Laurie",
"lastname": "Krajcik",
"password": "k%Y2I9oq",
"test": "1"
},
"pairedItem": { "item": 0 }
},
{
"json": {
"uid": "53fc09df-5463-4f48-9fda-6500b1b77c82",
"email": "Elaine_Feeney@gmail.com",
"firstname": "Tracy",
"lastname": "Mraz",
"password": "t48s3-r",
"test": "1"
},
"pairedItem": { "item": 1 }
}
]
]
},
"source": { "main": [{ "previousNode": "Edit Fields" }] }
}
],
"metadata": {},
"waitingExecution": {},
"waitingExecutionSource": {}
}
}
}
@@ -0,0 +1,106 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "df3ea0b2-913b-4736-a3c6-f61b35abd1e1",
"name": "When clicking Execute workflow"
},
{
"parameters": {
"category": "randomData",
"randomDataCount": 3
},
"type": "n8n-nodes-base.debugHelper",
"typeVersion": 1,
"position": [280, 0],
"id": "e31f942c-876a-43ae-b883-0b7566d44750",
"name": "DebugHelper"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "049c69e3-969d-4df9-bf93-e44c1da06ba1",
"name": "test",
"value": "1",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [500, 0],
"id": "eb20f04e-875f-4a2d-853c-f2e30014b821",
"name": "Edit Fields"
},
{
"type": "n8n-nodes-base.set",
"typeVersion": 2,
"position": [720, 0],
"id": "d4e8b6a2-cd73-452d-b5f0-986753f5dc4a",
"name": "Set"
}
],
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "DebugHelper",
"type": "main",
"index": 0
}
]
]
},
"DebugHelper": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"Edit Fields": [
{
"uid": "d42c6385-12f2-4486-92b5-eebd2e95d161",
"email": "Joanna_Willms@yahoo.com",
"firstname": "Laurie",
"lastname": "Krajcik",
"password": "k%Y2I9oq",
"test": "1"
},
{
"uid": "53fc09df-5463-4f48-9fda-6500b1b77c82",
"email": "Elaine_Feeney@gmail.com",
"firstname": "Tracy",
"lastname": "Mraz",
"password": "t48s3-r",
"test": "1"
}
]
}
}
@@ -0,0 +1,7 @@
{
"data": {
"resultData": {
"runData": {}
}
}
}
@@ -0,0 +1,97 @@
{
"meta": {
"instanceId": "a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0"
},
"nodes": [
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3058c300-b377-41b7-9c90-a01372f9b581",
"name": "firstName",
"value": "Joe",
"type": "string"
},
{
"id": "bb871662-c23c-4234-ac0c-b78c279bbf34",
"name": "lastName",
"value": "Smith",
"type": "string"
}
]
},
"options": {}
},
"id": "baee2bf4-5083-4cbe-8e51-4eddcf859ef5",
"name": "PinnedSet",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [1120, 380]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a482f1fd-4815-4da4-a733-7beafb43c500",
"name": "test",
"value": "={{ $('PinnedSet').all().json }}\n{{ $('PinnedSet').item.json.firstName }}\n{{ $('PinnedSet').first().json.firstName }}\n{{ $('PinnedSet').itemMatching(0).json.firstName }}\n{{ $('PinnedSet').itemMatching(1).json.firstName }}\n{{ $('PinnedSet').last().json.firstName }}\n{{ $('PinnedSet').all()[0].json.firstName }}\n{{ $('PinnedSet').all()[1].json.firstName }}\n\n{{ $input.first().json.firstName }}\n{{ $input.last().json.firstName }}\n{{ $input.item.json.firstName }}\n\n{{ $json.firstName }}\n{{ $data.firstName }}\n\n{{ $items()[0].json.firstName }}",
"type": "string"
}
]
},
"options": {}
},
"id": "2a543169-e2c1-4764-ac63-09534310b2b9",
"name": "NotPinnedSet1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [1360, 380]
},
{
"parameters": {},
"id": "f36672e5-8c87-480e-a5b8-de9da6b63192",
"name": "Start",
"type": "n8n-nodes-base.manualTrigger",
"position": [920, 380],
"typeVersion": 1
}
],
"connections": {
"PinnedSet": {
"main": [
[
{
"node": "NotPinnedSet1",
"type": "main",
"index": 0
}
]
]
},
"Start": {
"main": [
[
{
"node": "PinnedSet",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"PinnedSet": [
{
"firstName": "Joe",
"lastName": "Smith"
},
{
"firstName": "Joan",
"lastName": "Summers"
}
]
}
}
@@ -0,0 +1,117 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"_custom": {
"type": "reactive",
"stateTypeName": "Reactive",
"value": {
"Manual trigger": [
{
"_custom": {
"type": "reactive",
"stateTypeName": "Reactive",
"value": {
"hints": [],
"startTime": 1738314562475,
"executionTime": 1,
"source": [],
"executionStatus": "success",
"data": { "main": [[{ "json": {}, "pairedItem": { "item": 0 } }]] }
}
}
}
],
"Edit Fields": [
{
"_custom": {
"type": "reactive",
"stateTypeName": "Reactive",
"value": {
"hints": [],
"startTime": 1738314562477,
"executionTime": 0,
"source": [{ "previousNode": "Manual trigger" }],
"executionStatus": "success",
"data": {
"main": [[{ "json": { "foo": "test" }, "pairedItem": { "item": 0 } }]]
}
}
}
}
],
"Execute Workflow": [
{
"hints": [],
"startTime": 1738314562478,
"executionTime": 2,
"source": [{ "previousNode": "Edit Fields" }],
"executionStatus": "error",
"error": {
"level": "error",
"tags": { "packageName": "cli" },
"extra": { "workflowId": "1.2" },
"message": "Workflow does not exist.",
"stack": "Error: Workflow does not exist.\n at getWorkflowData (/Users/miloradfilipovic/workspace/n8n/packages/cli/src/workflow-execute-additional-data.ts:124:10)\n at Object.executeWorkflow (/Users/miloradfilipovic/workspace/n8n/packages/cli/src/workflow-execute-additional-data.ts:155:4)\n at ExecuteContext.executeWorkflow (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts:120:18)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts:397:50)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:8)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1503:27\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2064:11"
}
}
]
}
}
},
"pinData": {},
"lastNodeExecuted": "Execute Workflow",
"error": {
"level": "error",
"tags": { "packageName": "cli" },
"extra": { "workflowId": "1.2" },
"message": "Workflow does not exist.",
"stack": "Error: Workflow does not exist.\n at getWorkflowData (/Users/miloradfilipovic/workspace/n8n/packages/cli/src/workflow-execute-additional-data.ts:124:10)\n at Object.executeWorkflow (/Users/miloradfilipovic/workspace/n8n/packages/cli/src/workflow-execute-additional-data.ts:155:4)\n at ExecuteContext.executeWorkflow (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts:120:18)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts:397:50)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:8)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1503:27\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2064:11"
}
},
"executionData": {
"contextData": {},
"nodeExecutionStack": [
{
"node": {
"parameters": {
"operation": "call_workflow",
"source": "database",
"workflowId": {
"__rl": true,
"mode": "id",
"value": "=1.2",
"cachedResultName": "=1.2"
},
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {},
"matchingColumns": [],
"schema": [],
"attemptToConvertTypes": false,
"convertFieldsToString": true
},
"mode": "once",
"options": {}
},
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [120, -100],
"id": "62717ac7-614d-4e3f-b2ec-1e28688068c4",
"name": "Execute Workflow"
},
"data": { "main": [[{ "json": { "foo": "test" }, "pairedItem": { "item": 0 } }]] },
"source": { "main": [{ "previousNode": "Edit Fields" }] }
}
],
"metadata": {},
"waitingExecution": {},
"waitingExecutionSource": {}
},
"mode": "manual",
"startedAt": "2024-02-08T15:45:18.848Z",
"stoppedAt": "2024-02-08T15:45:18.862Z",
"status": "success"
}
}
@@ -0,0 +1,80 @@
{
"nodes": [
{
"id": "804e5ba7-4b1d-48c2-abfa-a36717a9fa66",
"name": "Manual trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-320, -100],
"parameters": {}
},
{
"id": "f995b1a2-8a49-4f0c-ae0d-8fb4c600cdef",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [-100, -100],
"parameters": {
"assignments": {
"assignments": [
{
"id": "f4d80089-a3d7-470f-8c07-dec07e37f339",
"name": "foo",
"value": "={{ test }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"id": "62717ac7-614d-4e3f-b2ec-1e28688068c4",
"name": "Execute Workflow",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [120, -100],
"parameters": {
"workflowId": {
"__rl": true,
"value": "={{ $json.foo }}",
"mode": "id"
},
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {},
"matchingColumns": [],
"schema": [],
"attemptToConvertTypes": false,
"convertFieldsToString": true
},
"options": { "waitForSubWorkflow": "={{ true }}" }
}
}
],
"connections": {
"Manual trigger": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "Execute Workflow",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}
@@ -0,0 +1,270 @@
/* eslint-disable n8n-local-rules/no-interpolation-in-regular-string */
import { FROM_AI_AUTO_GENERATED_MARKER } from '../src/constants';
import {
extractFromAICalls,
traverseNodeParameters,
type FromAIArgument,
generateZodSchema,
isFromAIOnlyExpression,
findDisallowedChatToolExpressions,
} from '../src/from-ai-parse-utils';
// Note that for historic reasons a lot of testing of this file happens indirectly in `packages/core/test/CreateNodeAsTool.test.ts`
describe('extractFromAICalls', () => {
test.each<[string, [unknown, unknown, unknown, unknown]]>([
['$fromAI("a", "b", "string")', ['a', 'b', 'string', undefined]],
['$fromAI("a", "b", "number", 5)', ['a', 'b', 'number', 5]],
['$fromAI("a", "b", "number", "5")', ['a', 'b', 'number', 5]],
['$fromAI("a", "`", "number", 5)', ['a', '`', 'number', 5]],
['$fromAI("a", "\\`", "number", 5)', ['a', '`', 'number', 5]], // this is a bit surprising, but intended
['$fromAI("a", "\\n", "number", 5)', ['a', 'n', 'number', 5]], // this is a bit surprising, but intended
['{{ $fromAI("a", "b", "boolean") }}', ['a', 'b', 'boolean', undefined]],
['{{ $fromAI("a", "b", "boolean", "true") }}', ['a', 'b', 'boolean', true]],
['{{ $fromAI("a", "b", "boolean", "false") }}', ['a', 'b', 'boolean', false]],
['{{ $fromAI("a", "b", "boolean", true) }}', ['a', 'b', 'boolean', true]],
['{{ $fromAI("a", "b", "string", "") }}', ['a', 'b', 'string', '']],
['{{ $fromAI("a", "b", "string", "null") }}', ['a', 'b', 'string', 'null']],
['{{ $fromAI("a", "b", "string", "5") }}', ['a', 'b', 'string', '5']],
['{{ $fromAI("a", "b", "string", "true") }}', ['a', 'b', 'string', 'true']],
['{{ $fromAI("a", "b", "string", "{}") }}', ['a', 'b', 'string', '{}']],
])('should parse args as expected for %s', (formula, [key, description, type, defaultValue]) => {
expect(extractFromAICalls(formula)).toEqual([
{
key,
description,
type,
defaultValue,
},
]);
});
test.each([
['$fromAI("a", "b", "c")'],
['$fromAI("a", "b", "string"'],
['$fromAI("a", "b", "string, "d")'],
])('should throw as expected for %s', (formula) => {
expect(() => extractFromAICalls(formula)).toThrowError();
});
it('supports multiple calls', () => {
const code = '$fromAI("a", "b", "number"); $fromAI("c", "d", "string")';
expect(extractFromAICalls(code)).toEqual([
{
key: 'a',
description: 'b',
type: 'number',
defaultValue: undefined,
},
{
key: 'c',
description: 'd',
type: 'string',
defaultValue: undefined,
},
]);
});
it('supports no calls', () => {
const code = 'fromAI("a", "b", "number")';
expect(extractFromAICalls(code)).toEqual([]);
});
});
describe('traverseNodeParameters', () => {
test.each<[string | string[] | Record<string, string>, [unknown, unknown, unknown, unknown]]>([
['$fromAI("a", "b", "string")', ['a', 'b', 'string', undefined]],
['$fromAI("a", "b", "number", 5)', ['a', 'b', 'number', 5]],
['{{ $fromAI("a", "b", "boolean") }}', ['a', 'b', 'boolean', undefined]],
[{ a: '{{ $fromAI("a", "b", "boolean") }}', b: 'five' }, ['a', 'b', 'boolean', undefined]],
[
['red', '{{ $fromAI("a", "b", "boolean") }}'],
['a', 'b', 'boolean', undefined],
],
])(
'should parse args as expected for %s',
(parameters, [key, description, type, defaultValue]) => {
const out: FromAIArgument[] = [];
traverseNodeParameters(parameters, out);
expect(out).toEqual([
{
key,
description,
type,
defaultValue,
},
]);
},
);
});
describe('JSON Type Parsing via generateZodSchema', () => {
it('should correctly parse a JSON parameter without default', () => {
// Use an actual $fromAI call string via extractFromAICalls:
const [arg] = extractFromAICalls(
'$fromAI("jsonWithoutDefault", "JSON parameter without default", "json")',
);
const schema = generateZodSchema(arg);
// Valid non-empty JSON objects should pass.
expect(() => schema.parse({ key: 'value' })).not.toThrow();
expect(schema.parse({ key: 'value' })).toEqual({ key: 'value' });
// Parsing an empty object should throw a validation error.
expect(() => schema.parse({})).toThrowError(
/Value must be a non-empty object or a non-empty array/,
);
});
it('should correctly parse a JSON parameter with a valid default', () => {
const [arg] = extractFromAICalls(
'$fromAI("jsonWithValidDefault", "JSON parameter with valid default", "json", "{"key": "defaultValue"}")',
);
const schema = generateZodSchema(arg);
// The default value is now stored as a parsed object.
expect(schema._def.defaultValue()).toEqual({ key: 'defaultValue' });
});
it('should parse a JSON parameter with an empty default', () => {
const [arg] = extractFromAICalls(
'$fromAI("jsonEmptyDefault", "JSON parameter with empty default", "json", "{}")',
);
const schema = generateZodSchema(arg);
// The default value is stored as an empty object.
expect(schema._def.defaultValue()).toEqual({});
// Parsing an empty object should throw a validation error.
expect(() => schema.parse({})).toThrowError(
/Value must be a non-empty object or a non-empty array/,
);
});
it('should use provided JSON value over the default value', () => {
const [arg] = extractFromAICalls(
'$fromAI("jsonParamCustom", "JSON parameter with custom default", "json", "{"initial": "value"}")',
);
const schema = generateZodSchema(arg);
// Check that the stored default value parses to the expected object.
expect(schema._def.defaultValue()).toEqual({ initial: 'value' });
// When a new valid value is provided, the schema should use it.
const newValue = { newKey: 'newValue' };
expect(schema.parse(newValue)).toEqual(newValue);
});
});
describe('isFromAIOnlyExpression', () => {
it.each([
'={{ $fromAI("key", "desc", "string") }}',
'={{ $fromAI("key") }}',
'={{ $fromAI("key", "a description with (parens)") }}',
`={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI("key", "desc") }}`,
'={{ $fromAI("key", "desc", "number", 5) }}',
'={{ $fromAI( "key" ) }}',
'={{ $fromAI("key", ``, "boolean") }}',
'={{ $fromAI("key", `plain backtick desc`) }}',
])('should accept valid $fromAI-only expression: %s', (expr) => {
expect(isFromAIOnlyExpression(expr)).toBe(true);
});
it.each([
'={{ $fromAI("key") + $env.SECRET }}',
'={{ $fromAI("key"); fetch("x") }}',
'={{ $fromAI("key") + " extra" }}',
'={{ $json.field }}',
'={{ $env.SECRET }}',
'={{ 1 + 2 }}',
'={{ $fromAI("key") && true }}',
'={{ $workflow.name }}',
'={{ $fromAI(evil()) }}',
'={{ $fromAI(require("child_process").exec("rm -rf /")) }}',
'={{ $fromAI("key", getSecret()) }}',
'={{ $fromAI(`${evil()}`) }}',
'={{ $fromAI(`prefix${evil()}suffix`) }}',
'={{ $fromAI("key", `${$env.SECRET}`) }}',
'={{ $fromAI($env.SECRET) }}',
'={{ $fromAI("key", "desc" + $env.SECRET) }}',
'={{ $fromAI("key", true ? $env.SECRET : "x") }}',
'={{ $fromAI(eval`code`) }}',
])('should reject expression with extra content: %s', (expr) => {
expect(isFromAIOnlyExpression(expr)).toBe(false);
});
it('should reject plain strings', () => {
expect(isFromAIOnlyExpression('just a string')).toBe(false);
});
it('should reject empty expression', () => {
expect(isFromAIOnlyExpression('={{ }}')).toBe(false);
});
it('should handle unbalanced parentheses gracefully', () => {
expect(isFromAIOnlyExpression('={{ $fromAI("key" }}')).toBe(false);
});
});
describe('findDisallowedChatToolExpressions', () => {
it('should return empty array for plain values', () => {
expect(findDisallowedChatToolExpressions({ url: 'https://example.com', count: 5 })).toEqual([]);
});
it('should return empty array for $fromAI-only expressions', () => {
expect(
findDisallowedChatToolExpressions({
url: '={{ $fromAI("url", "The URL") }}',
body: '={{ $fromAI("body", "Request body") }}',
}),
).toEqual([]);
});
it('should detect disallowed expressions in flat objects', () => {
const result = findDisallowedChatToolExpressions({
url: '={{ $env.API_URL }}',
name: 'valid',
});
expect(result).toEqual([{ path: 'url', value: '={{ $env.API_URL }}' }]);
});
it('should detect disallowed expressions in nested objects', () => {
const result = findDisallowedChatToolExpressions({
options: {
headers: {
value: '={{ $json.token }}',
},
},
});
expect(result).toEqual([{ path: 'options.headers.value', value: '={{ $json.token }}' }]);
});
it('should detect disallowed expressions in arrays', () => {
const result = findDisallowedChatToolExpressions({
items: ['valid', '={{ $env.SECRET }}'],
});
expect(result).toEqual([{ path: 'items[1]', value: '={{ $env.SECRET }}' }]);
});
it('should return multiple violations', () => {
const result = findDisallowedChatToolExpressions({
a: '={{ $env.A }}',
b: '={{ $env.B }}',
c: '={{ $fromAI("key") }}',
});
expect(result).toHaveLength(2);
expect(result.map((v) => v.path)).toEqual(['a', 'b']);
});
it('should handle mixed valid and invalid in nested arrays of objects', () => {
const result = findDisallowedChatToolExpressions({
headers: [
{ name: 'Auth', value: '={{ $env.TOKEN }}' },
{ name: 'Content-Type', value: 'application/json' },
],
});
expect(result).toEqual([{ path: 'headers[0].value', value: '={{ $env.TOKEN }}' }]);
});
});
@@ -0,0 +1,484 @@
import {
getInputEdges,
getOutputEdges,
getRootNodes,
getLeafNodes,
parseExtractableSubgraphSelection,
hasPath,
buildAdjacencyList,
} from '../../src/graph/graph-utils';
import type { IConnection, IConnections, NodeConnectionType } from '../../src/index';
function makeConnection(
node: string,
index: number = 0,
type: NodeConnectionType = 'main',
): IConnection {
return {
node,
index,
type,
};
}
describe('graphUtils', () => {
describe('getInputEdges', () => {
it('should return edges leading into the graph', () => {
const graphIds = new Set(['B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
]);
const result = getInputEdges(graphIds, adjacencyList);
expect(result).toEqual([['A', makeConnection('B')]]);
});
it('should return an empty array if there are no input edges', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set()],
]);
const result = getInputEdges(graphIds, adjacencyList);
expect(result).toEqual([]);
});
});
describe('getOutputEdges', () => {
it('should return edges leading out of the graph', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set()],
]);
const result = getOutputEdges(graphIds, adjacencyList);
expect(result).toEqual([['B', makeConnection('C')]]);
});
it('should return an empty array if there are no output edges', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
]);
const result = getOutputEdges(graphIds, adjacencyList);
expect(result).toEqual([]);
});
});
describe('getRootNodes', () => {
it('should return root nodes of the graph', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
]);
const result = getRootNodes(graphIds, adjacencyList);
expect(result).toEqual(new Set(['A', 'C']));
});
it('should return all nodes if there are no incoming edges', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>();
const result = getRootNodes(graphIds, adjacencyList);
expect(result).toEqual(new Set(['A', 'B']));
});
});
describe('getLeafNodes', () => {
it('should return leaf nodes of the graph', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set()],
]);
const result = getLeafNodes(graphIds, adjacencyList);
expect(result).toEqual(new Set(['C']));
});
it('should return all nodes if there are no outgoing edges', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set()],
['B', new Set()],
]);
const result = getLeafNodes(graphIds, adjacencyList);
expect(result).toEqual(new Set(['A', 'B']));
});
});
describe('parseExtractableSubgraphSelection', () => {
it('should return successfully for a valid extractable subgraph', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['C', new Set([makeConnection('A')])],
['A', new Set([makeConnection('B')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: 'A', end: undefined });
});
it('should return successfully for multiple edges into single input node', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['X', new Set([makeConnection('A')])],
['Y', new Set([makeConnection('A')])],
['A', new Set([makeConnection('B')])],
['B', new Set()],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: 'A', end: undefined });
});
it('should return successfully for multiple edges from single output nodes', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('X'), makeConnection('Y')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: undefined, end: 'B' });
});
it('should return errors for input edge to non-root node', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['X', new Set([makeConnection('B')])],
['A', new Set([makeConnection('B')])],
['B', new Set()],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual([
{
errorCode: 'Input Edge To Non-Root Node',
node: 'B',
},
]);
});
it('should return errors for output edge from non-leaf node', () => {
const graphIds = new Set(['A', 'B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B'), makeConnection('X')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual([
{
errorCode: 'Output Edge From Non-Leaf Node',
node: 'A',
},
]);
});
it('should return successfully for multiple root nodes with 1 input', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('C')])],
['B', new Set([makeConnection('C')])],
['X', new Set([makeConnection('A')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: 'A', end: undefined });
});
it('should return an error for multiple root nodes with inputs', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('C')])],
['B', new Set([makeConnection('C')])],
['X', new Set([makeConnection('A')])],
['Y', new Set([makeConnection('B')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual([
{
errorCode: 'Multiple Input Nodes',
nodes: new Set(['A', 'B']),
},
]);
});
it('should return successfully for multiple leaf nodes with 1 output', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B'), makeConnection('C')])],
['C', new Set([makeConnection('X')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: undefined, end: 'C' });
});
it('should return an error for multiple leaf nodes with outputs', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B'), makeConnection('C')])],
['B', new Set([makeConnection('X')])],
['C', new Set([makeConnection('X')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual([
{
errorCode: 'Multiple Output Nodes',
nodes: new Set(['B', 'C']),
},
]);
});
it('should return an error for a non-continuous selection', () => {
const graphIds = new Set(['A', 'D']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set([makeConnection('D')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual([
{
errorCode: 'No Continuous Path From Root To Leaf In Selection',
start: 'D',
end: 'A',
},
]);
});
it('should allow loop with node itself', () => {
const graphIds = new Set(['A']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('A')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: undefined, end: undefined });
});
it('should allow loop with node itself with input and output', () => {
const graphIds = new Set(['B']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('B'), makeConnection('C')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: 'B', end: 'B' });
});
it('should allow loop within selection', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set([makeConnection('A')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: undefined, end: undefined });
});
it('should allow loop within selection with input', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set([makeConnection('A')])],
['D', new Set([makeConnection('B')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: 'B', end: undefined });
});
it('should allow loop within selection with two inputs', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set([makeConnection('A')])],
['D', new Set([makeConnection('B')])],
['E', new Set([makeConnection('B')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual({ start: 'B', end: undefined });
});
it('should not allow loop within selection with inputs to different nodes', () => {
const graphIds = new Set(['A', 'B', 'C']);
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set([makeConnection('A')])],
['D', new Set([makeConnection('B')])],
['E', new Set([makeConnection('C')])],
]);
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
expect(result).toEqual([
{ errorCode: 'Input Edge To Non-Root Node', node: 'B' },
{ errorCode: 'Input Edge To Non-Root Node', node: 'C' },
]);
});
});
describe('hasPath', () => {
it('should return true for a direct path between start and end', () => {
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
]);
const result = hasPath('A', 'C', adjacencyList);
expect(result).toBe(true);
});
it('should return false if there is no path between start and end', () => {
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['C', new Set([makeConnection('D')])],
]);
const result = hasPath('A', 'D', adjacencyList);
expect(result).toBe(false);
});
it('should return true for a path with multiple intermediate nodes', () => {
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set([makeConnection('D')])],
]);
const result = hasPath('A', 'D', adjacencyList);
expect(result).toBe(true);
});
it('should return false if the start node is not in the adjacency list', () => {
const adjacencyList = new Map<string, Set<IConnection>>([
['B', new Set([makeConnection('C')])],
['C', new Set([makeConnection('D')])],
]);
const result = hasPath('A', 'D', adjacencyList);
expect(result).toBe(false);
});
it('should return false if the end node is not in the adjacency list', () => {
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
]);
const result = hasPath('A', 'D', adjacencyList);
expect(result).toBe(false);
});
it('should return true for a cyclic graph where a path exists', () => {
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('C')])],
['C', new Set([makeConnection('A')])],
]);
const result = hasPath('A', 'C', adjacencyList);
expect(result).toBe(true);
});
it('should return false for a cyclic graph where no path exists', () => {
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B')])],
['B', new Set([makeConnection('A')])],
['C', new Set([makeConnection('D')])],
]);
const result = hasPath('A', 'D', adjacencyList);
expect(result).toBe(false);
});
it('should return true for a self-loop', () => {
const adjacencyList = new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('A')])],
]);
const result = hasPath('A', 'A', adjacencyList);
expect(result).toBe(true);
});
});
describe('buildAdjacencyList', () => {
it('should build an adjacency list from connections by source node', () => {
const connectionsBySourceNode: IConnections = {
A: {
main: [
[
{ node: 'B', index: 0, type: 'main' },
{ node: 'C', index: 1, type: 'main' },
],
],
},
B: {
main: [[{ node: 'D', index: 0, type: 'main' }]],
},
};
const result = buildAdjacencyList(connectionsBySourceNode);
expect(result).toEqual(
new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])],
['B', new Set([makeConnection('D', 0)])],
]),
);
});
it('should handle an empty connections object', () => {
const connectionsBySourceNode = {};
const result = buildAdjacencyList(connectionsBySourceNode);
expect(result).toEqual(new Map());
});
it('should handle connections with multiple types', () => {
const connectionsBySourceNode: IConnections = {
A: {
main: [[{ node: 'B', index: 0, type: 'main' }]],
ai_tool: [[{ node: 'C', index: 1, type: 'ai_tool' }]],
},
};
const result = buildAdjacencyList(connectionsBySourceNode);
expect(result).toEqual(
new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B', 0, 'main'), makeConnection('C', 1, 'ai_tool')])],
]),
);
});
it('should handle connections with multiple indices', () => {
const connectionsBySourceNode: IConnections = {
A: {
main: [[{ node: 'B', index: 0, type: 'main' }], [{ node: 'C', index: 1, type: 'main' }]],
},
};
const result = buildAdjacencyList(connectionsBySourceNode);
expect(result).toEqual(
new Map<string, Set<IConnection>>([
['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])],
]),
);
});
});
});
+18
View File
@@ -0,0 +1,18 @@
import { readFileSync } from 'fs';
import path from 'path';
import { NodeTypes as NodeTypesClass } from './node-types';
import type { INodeTypes } from '../src/interfaces';
let nodeTypesInstance: NodeTypesClass | undefined;
export function NodeTypes(): INodeTypes {
if (nodeTypesInstance === undefined) {
nodeTypesInstance = new NodeTypesClass();
}
return nodeTypesInstance;
}
const BASE_DIR = path.resolve(__dirname, '..');
export const readJsonFileSync = <T>(filePath: string) =>
JSON.parse(readFileSync(path.join(BASE_DIR, filePath), 'utf-8')) as T;
@@ -0,0 +1,49 @@
import { parseErrorMetadata } from '../src/metadata-utils';
describe('MetadataUtils', () => {
describe('parseMetadataFromError', () => {
const expectedMetadata = {
subExecution: {
executionId: '123',
workflowId: '456',
},
subExecutionsCount: 1,
};
it('should return undefined if error does not have response or both keys on the object', () => {
const error = { message: 'An error occurred' };
const result = parseErrorMetadata(error);
expect(result).toBeUndefined();
});
it('should return undefined if errorResponse only has workflowId key', () => {
const error = { errorResponse: { executionId: '123' } };
const result = parseErrorMetadata(error);
expect(result).toBeUndefined();
});
it('should return undefined if error only has executionId key', () => {
const error = { executionId: '123' };
const result = parseErrorMetadata(error);
expect(result).toBeUndefined();
});
it('should support executionId and workflowId key directly on the error object', () => {
const error = { executionId: '123', workflowId: '456' };
const result = parseErrorMetadata(error);
expect(result).toEqual(expectedMetadata);
});
it('should return undefined if error response does not have subworkflow data', () => {
const error = { errorResponse: { someKey: 'someValue' } };
const result = parseErrorMetadata(error);
expect(result).toBeUndefined();
});
it('should return metadata if error response has subworkflow data', () => {
const error = { errorResponse: { executionId: '123', workflowId: '456' } };
const result = parseErrorMetadata(error);
expect(result).toEqual(expectedMetadata);
});
});
});
+299
View File
@@ -0,0 +1,299 @@
import { UNKNOWN_ERROR_DESCRIPTION, UNKNOWN_ERROR_MESSAGE } from '../src/constants';
import { ExpressionError, NodeOperationError } from '../src/errors';
import { NodeApiError } from '../src/errors/node-api.error';
import type { INode, JsonObject } from '../src/interfaces';
const node: INode = {
id: '1',
name: 'Postgres node',
typeVersion: 2,
type: 'n8n-nodes-base.postgres',
position: [60, 760],
parameters: {
operation: 'executeQuery',
},
};
describe('NodeErrors tests', () => {
it('should return unknown error message', () => {
const nodeApiError = new NodeApiError(node, {});
expect(nodeApiError.message).toEqual(UNKNOWN_ERROR_MESSAGE);
});
it('should return the error message', () => {
const nodeApiError = new NodeApiError(node, { message: 'test error message' });
expect(nodeApiError.message).toEqual('test error message');
});
it('should return the error message defined in reason', () => {
const nodeApiError = new NodeApiError(node, { reason: { message: 'test error message' } });
expect(nodeApiError.message).toEqual('test error message');
});
it('should return the error message defined in options', () => {
const nodeApiError = new NodeApiError(node, {}, { message: 'test error message' });
expect(nodeApiError.message).toEqual('test error message');
});
it('should return description error message', () => {
const nodeApiError = new NodeApiError(node, { description: 'test error description' });
expect(nodeApiError.message).toEqual('test error description');
});
it('should return description as error message defined in reason', () => {
const nodeApiError = new NodeApiError(node, {
reason: { description: 'test error description' },
});
expect(nodeApiError.message).toEqual('test error description');
});
it('should return description as error message defined in options', () => {
const nodeApiError = new NodeApiError(node, {}, { description: 'test error description' });
expect(nodeApiError.message).toEqual('test error description');
});
it('should return default message for ECONNREFUSED', () => {
const nodeApiError = new NodeApiError(node, {
message: 'ECONNREFUSED',
});
expect(nodeApiError.message).toEqual(
'The service refused the connection - perhaps it is offline',
);
});
it('should return default message for 502', () => {
const nodeApiError = new NodeApiError(node, {
message: '502 Bad Gateway',
});
expect(nodeApiError.message).toEqual('Bad gateway - the service failed to handle your request');
});
it('should return default message for ENOTFOUND, NodeOperationError', () => {
const nodeOperationError = new NodeOperationError(node, 'ENOTFOUND test error message');
expect(nodeOperationError.message).toEqual(
'The connection cannot be established, this usually occurs due to an incorrect host (domain) value',
);
});
it('should return default message for ENOTFOUND, NodeApiError', () => {
const nodeApiError = new NodeApiError(node, { message: 'ENOTFOUND test error message' });
expect(nodeApiError.message).toEqual(
'The connection cannot be established, this usually occurs due to an incorrect host (domain) value',
);
});
it('should return default message for EEXIST based on code, NodeApiError', () => {
const nodeApiError = new NodeApiError(node, {
message: 'test error message',
code: 'EEXIST',
});
expect(nodeApiError.message).toEqual('The file or directory already exists');
});
it('should update description GETADDRINFO, NodeOperationError', () => {
const nodeOperationError = new NodeOperationError(node, 'GETADDRINFO test error message', {
description: 'test error description',
});
expect(nodeOperationError.message).toEqual('The server closed the connection unexpectedly');
//description should not include error message
expect(nodeOperationError.description).toEqual('test error description');
});
it('should remove description if it is equal to message, NodeOperationError', () => {
const nodeOperationError = new NodeOperationError(node, 'some text', {
description: 'some text',
});
expect(nodeOperationError.message).toEqual('some text');
expect(nodeOperationError.description).toEqual(undefined);
});
it('should use error description if no options do not provide one, NodeOperationError', () => {
const error = new ExpressionError('aMessage', { description: 'an error description' });
const nodeOperationError = new NodeOperationError(node, error);
expect(nodeOperationError.message).toEqual('aMessage');
expect(nodeOperationError.description).toEqual('an error description');
});
it('should use options description even if error provides one, NodeOperationError', () => {
const error = new ExpressionError('aMessage', { description: 'an error description' });
const nodeOperationError = new NodeOperationError(node, error, {
description: 'another description',
});
expect(nodeOperationError.message).toEqual('aMessage');
expect(nodeOperationError.description).toEqual('another description');
});
it('should remove description if it is equal to message, message provided in options take precedence over original, NodeApiError', () => {
const nodeApiError = new NodeApiError(
node,
{
message: 'original message',
},
{ message: 'new text', description: 'new text' },
);
expect(nodeApiError.message).toEqual('new text');
expect(nodeApiError.description).toEqual(undefined);
});
it('should return mapped message for MYMAPPEDMESSAGE, NodeOperationError', () => {
const nodeOperationError = new NodeOperationError(node, 'MYMAPPEDMESSAGE test error message', {
messageMapping: {
MYMAPPEDMESSAGE: 'test error message',
},
});
expect(nodeOperationError.message).toEqual('test error message');
});
it('should return mapped message for MYMAPPEDMESSAGE, NodeApiError', () => {
const nodeApiError = new NodeApiError(
node,
{ message: 'MYMAPPEDMESSAGE test error message' },
{
messageMapping: {
MYMAPPEDMESSAGE: 'test error message',
},
},
);
expect(nodeApiError.message).toEqual('test error message');
});
it('should return default message for EACCES, custom mapping not found, NodeOperationError', () => {
const nodeOperationError = new NodeOperationError(node, 'EACCES test error message', {
messageMapping: {
MYMAPPEDMESSAGE: 'test error message',
},
});
expect(nodeOperationError.message).toEqual(
'Forbidden by access permissions, make sure you have the right permissions',
);
});
});
describe('NodeApiError message and description logic', () => {
it('case: customMessage && customDescription, result: message === customMessage; description === customDescription', () => {
const apiError = { message: 'Original message', code: 404 };
const nodeApiError = new NodeApiError(node, apiError, {
message: 'Custom message',
description: 'Custom description',
});
expect(nodeApiError.message).toEqual('Custom message');
expect(nodeApiError.description).toEqual('Custom description');
expect(nodeApiError.messages).toContain('Original message');
});
it('case: customMessage && !customDescription && extractedMessage, result: message === customMessage; description === extractedMessage', () => {
const apiError = {
message: 'Original message',
code: 404,
response: { data: { error: { message: 'Extracted message' } } },
};
const nodeApiError = new NodeApiError(node, apiError, {
message: 'Custom message',
});
expect(nodeApiError.message).toEqual('Custom message');
expect(nodeApiError.description).toEqual('Extracted message');
expect(nodeApiError.messages).toContain('Original message');
});
it('case: customMessage && !customDescription && !extractedMessage, result: message === customMessage; !description', () => {
const apiError = {
message: '',
code: 404,
response: { data: { error: { foo: 'Extracted message' } } },
};
const nodeApiError = new NodeApiError(node, apiError, {
message: 'Custom message',
});
expect(nodeApiError.message).toEqual('Custom message');
expect(nodeApiError.description).toBeFalsy();
expect(nodeApiError.messages.length).toBe(0);
});
it('case: !customMessage && httpCodeMapping && extractedMessage, result: message === httpCodeMapping; description === extractedMessage', () => {
const apiError = {
message: 'Original message',
code: 404,
response: { data: { error: { message: 'Extracted message' } } },
};
const nodeApiError = new NodeApiError(node, apiError);
expect(nodeApiError.message).toEqual('The resource you are requesting could not be found');
expect(nodeApiError.description).toEqual('Extracted message');
expect(nodeApiError.messages).toContain('Original message');
});
it('case: !customMessage && httpCodeMapping && !extractedMessage, result: message === httpCodeMapping; !description', () => {
const apiError = {
message: '',
code: 500,
};
const nodeApiError = new NodeApiError(node, apiError);
expect(nodeApiError.message).toEqual('The service was not able to process your request');
expect(nodeApiError.description).toBeFalsy();
});
it('case: !customMessage && !httpCodeMapping && extractedMessage, result: message === extractedMessage; !description', () => {
const apiError = {
message: '',
code: 300,
response: { data: { error: { message: 'Extracted message' } } },
};
const nodeApiError = new NodeApiError(node, apiError);
expect(nodeApiError.message).toEqual('Extracted message');
expect(nodeApiError.description).toBeFalsy();
});
it('case: !customMessage && !httpCodeMapping && !extractedMessage, result: message === UNKNOWN_ERROR_MESSAGE; description === UNKNOWN_ERROR_DESCRIPTION', () => {
const apiError = {};
const nodeApiError = new NodeApiError(node, apiError);
expect(nodeApiError.message).toEqual(UNKNOWN_ERROR_MESSAGE);
expect(nodeApiError.description).toEqual(UNKNOWN_ERROR_DESCRIPTION);
});
it('case: Error code sent as "any"', () => {
const error = {
code: 400,
message: "Invalid value 'test' for viewId parameter.",
status: 'INVALID_ARGUMENT',
};
const [message, ...rest] = error.message.split('\n');
const description = rest.join('\n');
const httpCode = error.code as any;
const nodeApiError = new NodeApiError(node, error as JsonObject, {
message,
description,
httpCode,
});
expect(nodeApiError.message).toEqual(error.message);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,190 @@
import {
isNodeParameterValue,
isNodeParameters,
isValidNodeParameterValueType,
assertIsValidNodeParameterValueType,
isAssignmentCollectionValue,
} from '../../src/node-parameters/node-parameter-value-type-guard';
describe('node-parameter-value-type-guard', () => {
describe('isNodeParameterValue', () => {
it('should return true for primitives', () => {
expect(isNodeParameterValue('string')).toBe(true);
expect(isNodeParameterValue(42)).toBe(true);
expect(isNodeParameterValue(true)).toBe(true);
expect(isNodeParameterValue(false)).toBe(true);
expect(isNodeParameterValue(null)).toBe(true);
expect(isNodeParameterValue(undefined)).toBe(true);
});
it('should return false for non-primitives', () => {
expect(isNodeParameterValue({})).toBe(false);
expect(isNodeParameterValue([])).toBe(false);
expect(isNodeParameterValue(() => {})).toBe(false);
expect(isNodeParameterValue(Symbol('test'))).toBe(false);
});
});
describe('isNodeParameters', () => {
it('should return true for valid INodeParameters objects', () => {
expect(isNodeParameters({})).toBe(true);
expect(isNodeParameters({ key: 'value' })).toBe(true);
expect(isNodeParameters({ key: 123 })).toBe(true);
expect(isNodeParameters({ key: true })).toBe(true);
expect(isNodeParameters({ nested: { key: 'value' } })).toBe(true);
});
it('should return false for non-objects', () => {
expect(isNodeParameters('string')).toBe(false);
expect(isNodeParameters(123)).toBe(false);
expect(isNodeParameters(null)).toBe(false);
expect(isNodeParameters(undefined)).toBe(false);
expect(isNodeParameters([])).toBe(false);
});
it('should return false for objects with invalid values', () => {
expect(isNodeParameters({ key: () => {} })).toBe(false);
expect(isNodeParameters({ key: Symbol('test') })).toBe(false);
expect(isNodeParameters({ key: new Date() })).toBe(false);
});
it('should handle nested objects', () => {
expect(
isNodeParameters({
level1: {
level2: {
level3: 'value',
},
},
}),
).toBe(true);
expect(
isNodeParameters({
level1: {
level2: {
level3: () => {},
},
},
}),
).toBe(false);
});
});
describe('isAssignmentCollectionValue', () => {
it('should return true for valid assignment collections', () => {
expect(
isAssignmentCollectionValue({
assignments: [
{
id: '1',
name: 'test',
value: 'value',
},
],
}),
).toBe(true);
expect(isAssignmentCollectionValue({ assignments: [] })).toBe(true);
});
it('should return false for invalid assignment collections', () => {
expect(isAssignmentCollectionValue({})).toBe(false);
expect(isAssignmentCollectionValue({ assignments: 'not-array' })).toBe(false);
expect(
isAssignmentCollectionValue({
assignments: [
{
id: '1',
// missing name and value
},
],
}),
).toBe(false);
});
});
describe('isValidNodeParameterValueType', () => {
it('should return true for all valid types', () => {
// Primitives
expect(isValidNodeParameterValueType('string')).toBe(true);
expect(isValidNodeParameterValueType(123)).toBe(true);
expect(isValidNodeParameterValueType(true)).toBe(true);
expect(isValidNodeParameterValueType(null)).toBe(true);
expect(isValidNodeParameterValueType(undefined)).toBe(true);
// Objects
expect(isValidNodeParameterValueType({})).toBe(true);
expect(isValidNodeParameterValueType({ key: 'value' })).toBe(true);
// Arrays
expect(isValidNodeParameterValueType([])).toBe(true);
expect(isValidNodeParameterValueType(['string'])).toBe(true);
expect(isValidNodeParameterValueType([1, 2, 3])).toBe(true);
expect(isValidNodeParameterValueType([{ key: 'value' }])).toBe(true);
// Resource locator
expect(
isValidNodeParameterValueType({
__rl: true,
mode: 'id',
value: '123',
}),
).toBe(true);
// Resource mapper
expect(
isValidNodeParameterValueType({
mappingMode: 'defineBelow',
schema: [],
value: {},
}),
).toBe(true);
// Filter value
expect(
isValidNodeParameterValueType({
conditions: [],
combinator: 'and',
}),
).toBe(true);
// Assignment collection
expect(
isValidNodeParameterValueType({
assignments: [{ id: '1', name: 'test', value: 'value' }],
}),
).toBe(true);
});
it('should return false for invalid types', () => {
expect(isValidNodeParameterValueType(() => {})).toBe(false);
expect(isValidNodeParameterValueType(Symbol('test'))).toBe(false);
expect(isValidNodeParameterValueType(new Date())).toBe(false);
expect(isValidNodeParameterValueType({ key: () => {} })).toBe(false);
expect(isValidNodeParameterValueType([() => {}])).toBe(false);
});
});
describe('assertIsValidNodeParameterValueType', () => {
it('should not throw for valid values', () => {
expect(() => assertIsValidNodeParameterValueType('string')).not.toThrow();
expect(() => assertIsValidNodeParameterValueType(123)).not.toThrow();
expect(() => assertIsValidNodeParameterValueType({})).not.toThrow();
});
it('should throw for invalid values', () => {
expect(() => assertIsValidNodeParameterValueType(() => {})).toThrow(
'Value is not a valid NodeParameterValueType',
);
expect(() => assertIsValidNodeParameterValueType(Symbol('test'))).toThrow();
expect(() => assertIsValidNodeParameterValueType(new Date())).toThrow();
});
it('should support custom error messages', () => {
expect(() => assertIsValidNodeParameterValueType(() => {}, 'Custom error')).toThrow(
'Custom error',
);
});
});
});
@@ -0,0 +1,806 @@
import {
validateNodeParameters,
assertParamIsString,
assertParamIsNumber,
assertParamIsBoolean,
assertParamIsArray,
assertParamIsOfAnyTypes,
} from '../../src/node-parameters/parameter-type-validation';
import type { INode } from '../../src/interfaces';
describe('Type assertion functions', () => {
const mockNode: INode = {
id: 'test-node-id',
name: 'TestNode',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
describe('assertIsNodeParameters', () => {
it('should pass for valid object with all required parameters', () => {
const value = {
name: 'test',
age: 25,
active: true,
};
const parameters = {
name: { type: 'string' as const, required: true },
age: { type: 'number' as const, required: true },
active: { type: 'boolean' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should pass for valid object with optional parameters present', () => {
const value = {
name: 'test',
description: 'optional description',
};
const parameters = {
name: { type: 'string' as const, required: true },
description: { type: 'string' as const },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should pass for valid object with optional parameters missing', () => {
const value = {
name: 'test',
};
const parameters = {
name: { type: 'string' as const, required: true },
description: { type: 'string' as const },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should pass for valid array parameters', () => {
const value = {
tags: ['tag1', 'tag2'],
numbers: [1, 2, 3],
flags: [true, false],
};
const parameters = {
tags: { type: 'string[]' as const, required: true },
numbers: { type: 'number[]' as const, required: true },
flags: { type: 'boolean[]' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should pass for valid resource-locator parameter', () => {
const value = {
resource: {
__rl: true,
mode: 'list',
value: 'some-value',
},
};
const parameters = {
resource: { type: 'resource-locator' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should pass for valid object parameter', () => {
const value = {
config: {
setting1: 'value1',
setting2: 42,
},
};
const parameters = {
config: { type: 'object' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should pass for parameter with multiple allowed types', () => {
const value = {
multiType: 'string value',
};
const parameters = {
multiType: { type: ['string', 'number'] as Array<'string' | 'number'>, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
// Test with number value
const value2 = {
multiType: 42,
};
expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow();
});
it('should throw for null value', () => {
const parameters = {
name: { type: 'string' as const, required: true },
};
expect(() => validateNodeParameters(null, parameters, mockNode)).toThrow(
'Value is not a valid object',
);
});
it('should throw for non-object value', () => {
const parameters = {
name: { type: 'string' as const, required: true },
};
expect(() => validateNodeParameters('not an object', parameters, mockNode)).toThrow(
'Value is not a valid object',
);
expect(() => validateNodeParameters(123, parameters, mockNode)).toThrow(
'Value is not a valid object',
);
expect(() => validateNodeParameters(true, parameters, mockNode)).toThrow(
'Value is not a valid object',
);
});
it('should throw for missing required parameter', () => {
const value = {
// name is missing
};
const parameters = {
name: { type: 'string' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Required parameter "name" is missing',
);
});
it('should throw for parameter with wrong type', () => {
const value = {
name: 123, // should be string
};
const parameters = {
name: { type: 'string' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "name" does not match any of the expected types: string',
);
});
it('should throw for invalid array parameter', () => {
const value = {
tags: 'not an array',
};
const parameters = {
tags: { type: 'string[]' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "tags" does not match any of the expected types: string[]',
);
});
it('should throw for array with wrong element type', () => {
const value = {
tags: ['valid', 123, 'also valid'], // 123 is not a string
};
const parameters = {
tags: { type: 'string[]' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "tags" does not match any of the expected types: string[]',
);
});
it('should throw for invalid resource-locator parameter', () => {
const value = {
resource: {
// missing required properties
mode: 'list',
},
};
const parameters = {
resource: { type: 'resource-locator' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "resource" does not match any of the expected types: resource-locator',
);
});
it('should throw for invalid object parameter', () => {
const value = {
config: 'not an object',
};
const parameters = {
config: { type: 'object' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "config" does not match any of the expected types: object',
);
});
it('should throw for parameter that matches none of the allowed types', () => {
const value = {
multiType: true, // should be string or number
};
const parameters = {
multiType: { type: ['string', 'number'] as Array<'string' | 'number'>, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "multiType" does not match any of the expected types: string or number',
);
});
it('should handle empty parameter definition', () => {
const value = {
extra: 'should be ignored',
};
const parameters = {};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle complex nested scenarios', () => {
const value = {
name: 'test',
tags: ['tag1', 'tag2'],
config: {
enabled: true,
timeout: 5000,
},
resource: {
__rl: true,
mode: 'id',
value: '12345',
},
optionalField: undefined,
};
const parameters = {
name: { type: 'string' as const, required: true },
tags: { type: 'string[]' as const, required: true },
config: { type: 'object' as const, required: true },
resource: { type: 'resource-locator' as const, required: true },
optionalField: { type: 'string' as const },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle empty arrays', () => {
const value = {
emptyTags: [],
};
const parameters = {
emptyTags: { type: 'string[]' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle null values for optional parameters', () => {
const value = {
name: 'test',
optionalField: null,
};
const parameters = {
name: { type: 'string' as const, required: true },
optionalField: { type: 'string' as const },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "optionalField" does not match any of the expected types: string',
);
});
it('should handle resource-locator with additional properties', () => {
const value = {
resource: {
__rl: true,
mode: 'list',
value: 'some-value',
extraProperty: 'ignored',
},
};
const parameters = {
resource: { type: 'resource-locator' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
});
describe('assertParamIsBoolean', () => {
it('should pass for valid boolean values', () => {
expect(() => assertParamIsBoolean('testParam', true, mockNode)).not.toThrow();
expect(() => assertParamIsBoolean('testParam', false, mockNode)).not.toThrow();
});
it('should throw for non-boolean values', () => {
expect(() => assertParamIsBoolean('testParam', 'true', mockNode)).toThrow(
'Parameter "testParam" is not boolean',
);
expect(() => assertParamIsBoolean('testParam', 1, mockNode)).toThrow(
'Parameter "testParam" is not boolean',
);
expect(() => assertParamIsBoolean('testParam', 0, mockNode)).toThrow(
'Parameter "testParam" is not boolean',
);
expect(() => assertParamIsBoolean('testParam', null, mockNode)).toThrow(
'Parameter "testParam" is not boolean',
);
expect(() => assertParamIsBoolean('testParam', undefined, mockNode)).toThrow(
'Parameter "testParam" is not boolean',
);
});
});
describe('assertIsString', () => {
it('should pass for valid string', () => {
expect(() => assertParamIsString('testParam', 'hello', mockNode)).not.toThrow();
});
it('should throw for non-string values', () => {
expect(() => assertParamIsString('testParam', 123, mockNode)).toThrow(
'Parameter "testParam" is not string',
);
expect(() => assertParamIsString('testParam', true, mockNode)).toThrow(
'Parameter "testParam" is not string',
);
expect(() => assertParamIsString('testParam', null, mockNode)).toThrow(
'Parameter "testParam" is not string',
);
expect(() => assertParamIsString('testParam', undefined, mockNode)).toThrow(
'Parameter "testParam" is not string',
);
});
});
describe('assertIsNumber', () => {
it('should pass for valid number', () => {
expect(() => assertParamIsNumber('testParam', 123, mockNode)).not.toThrow();
expect(() => assertParamIsNumber('testParam', 0, mockNode)).not.toThrow();
expect(() => assertParamIsNumber('testParam', -5.5, mockNode)).not.toThrow();
});
it('should throw for non-number values', () => {
expect(() => assertParamIsNumber('testParam', '123', mockNode)).toThrow(
'Parameter "testParam" is not number',
);
expect(() => assertParamIsNumber('testParam', true, mockNode)).toThrow(
'Parameter "testParam" is not number',
);
expect(() => assertParamIsNumber('testParam', null, mockNode)).toThrow(
'Parameter "testParam" is not number',
);
expect(() => assertParamIsNumber('testParam', undefined, mockNode)).toThrow(
'Parameter "testParam" is not number',
);
});
});
describe('assertIsArray', () => {
const isString = (val: unknown): val is string => typeof val === 'string';
const isNumber = (val: unknown): val is number => typeof val === 'number';
it('should pass for valid array with correct element types', () => {
expect(() =>
assertParamIsArray('testParam', ['a', 'b', 'c'], isString, mockNode),
).not.toThrow();
expect(() => assertParamIsArray('testParam', [1, 2, 3], isNumber, mockNode)).not.toThrow();
expect(() => assertParamIsArray('testParam', [], isString, mockNode)).not.toThrow(); // empty array
});
it('should throw for non-array values', () => {
expect(() => assertParamIsArray('testParam', 'not array', isString, mockNode)).toThrow(
'Parameter "testParam" is not an array',
);
expect(() => assertParamIsArray('testParam', { length: 3 }, isString, mockNode)).toThrow(
'Parameter "testParam" is not an array',
);
});
it('should throw for array with incorrect element types', () => {
expect(() => assertParamIsArray('testParam', ['a', 1, 'c'], isString, mockNode)).toThrow(
'Parameter "testParam" has elements that don\'t match expected types',
);
expect(() => assertParamIsArray('testParam', [1, 'b', 3], isNumber, mockNode)).toThrow(
'Parameter "testParam" has elements that don\'t match expected types',
);
});
});
describe('assertParamIsOfAnyTypes', () => {
it('should pass for string value when string is in types array', () => {
expect(() =>
assertParamIsOfAnyTypes('testParam', 'hello', ['string'], mockNode),
).not.toThrow();
});
it('should pass for number value when number is in types array', () => {
expect(() => assertParamIsOfAnyTypes('testParam', 42, ['number'], mockNode)).not.toThrow();
});
it('should pass for boolean value when boolean is in types array', () => {
expect(() => assertParamIsOfAnyTypes('testParam', true, ['boolean'], mockNode)).not.toThrow();
expect(() =>
assertParamIsOfAnyTypes('testParam', false, ['boolean'], mockNode),
).not.toThrow();
});
it('should pass for string when multiple types include string', () => {
expect(() =>
assertParamIsOfAnyTypes('testParam', 'hello', ['string', 'number'], mockNode),
).not.toThrow();
});
it('should pass for number when multiple types include number', () => {
expect(() =>
assertParamIsOfAnyTypes('testParam', 42, ['string', 'number'], mockNode),
).not.toThrow();
});
it('should pass for boolean when multiple types include boolean', () => {
expect(() =>
assertParamIsOfAnyTypes('testParam', true, ['string', 'boolean'], mockNode),
).not.toThrow();
});
it('should pass for value matching any of three types', () => {
expect(() =>
assertParamIsOfAnyTypes('testParam', 'test', ['string', 'number', 'boolean'], mockNode),
).not.toThrow();
expect(() =>
assertParamIsOfAnyTypes('testParam', 123, ['string', 'number', 'boolean'], mockNode),
).not.toThrow();
expect(() =>
assertParamIsOfAnyTypes('testParam', false, ['string', 'number', 'boolean'], mockNode),
).not.toThrow();
});
it('should throw for string when types array does not include string', () => {
expect(() => assertParamIsOfAnyTypes('testParam', 'hello', ['number'], mockNode)).toThrow(
'Parameter "testParam" must be number',
);
});
it('should throw for number when types array does not include number', () => {
expect(() => assertParamIsOfAnyTypes('testParam', 42, ['string'], mockNode)).toThrow(
'Parameter "testParam" must be string',
);
});
it('should throw for boolean when types array does not include boolean', () => {
expect(() => assertParamIsOfAnyTypes('testParam', true, ['string'], mockNode)).toThrow(
'Parameter "testParam" must be string',
);
});
it('should throw for value that matches none of multiple types', () => {
expect(() =>
assertParamIsOfAnyTypes('testParam', 'hello', ['number', 'boolean'], mockNode),
).toThrow('Parameter "testParam" must be number or boolean');
});
it('should throw for null value', () => {
expect(() => assertParamIsOfAnyTypes('testParam', null, ['string'], mockNode)).toThrow(
'Parameter "testParam" must be string',
);
expect(() =>
assertParamIsOfAnyTypes('testParam', null, ['string', 'number'], mockNode),
).toThrow('Parameter "testParam" must be string or number');
});
it('should throw for undefined value', () => {
expect(() => assertParamIsOfAnyTypes('testParam', undefined, ['string'], mockNode)).toThrow(
'Parameter "testParam" must be string',
);
expect(() =>
assertParamIsOfAnyTypes('testParam', undefined, ['boolean', 'number'], mockNode),
).toThrow('Parameter "testParam" must be boolean or number');
});
it('should throw for object when primitive types are expected', () => {
expect(() =>
assertParamIsOfAnyTypes('testParam', {}, ['string', 'number'], mockNode),
).toThrow('Parameter "testParam" must be string or number');
expect(() => assertParamIsOfAnyTypes('testParam', [], ['boolean'], mockNode)).toThrow(
'Parameter "testParam" must be boolean',
);
});
it('should handle special number values correctly', () => {
expect(() => assertParamIsOfAnyTypes('testParam', NaN, ['number'], mockNode)).not.toThrow();
expect(() =>
assertParamIsOfAnyTypes('testParam', Infinity, ['number'], mockNode),
).not.toThrow();
expect(() =>
assertParamIsOfAnyTypes('testParam', -Infinity, ['number'], mockNode),
).not.toThrow();
});
it('should handle empty string correctly', () => {
expect(() => assertParamIsOfAnyTypes('testParam', '', ['string'], mockNode)).not.toThrow();
});
it('should handle zero correctly', () => {
expect(() => assertParamIsOfAnyTypes('testParam', 0, ['number'], mockNode)).not.toThrow();
});
it('should format error message correctly for single type', () => {
expect(() => assertParamIsOfAnyTypes('myParam', 123, ['string'], mockNode)).toThrow(
'Parameter "myParam" must be string',
);
});
it('should format error message correctly for two types', () => {
expect(() =>
assertParamIsOfAnyTypes('myParam', 'test', ['number', 'boolean'], mockNode),
).toThrow('Parameter "myParam" must be number or boolean');
});
it('should format error message correctly for three types', () => {
expect(() =>
assertParamIsOfAnyTypes('myParam', {}, ['string', 'number', 'boolean'], mockNode),
).toThrow('Parameter "myParam" must be string or number or boolean');
});
it('should handle readonly array types correctly', () => {
const types = ['string', 'number'] as const;
expect(() => assertParamIsOfAnyTypes('testParam', 'hello', types, mockNode)).not.toThrow();
expect(() => assertParamIsOfAnyTypes('testParam', 42, types, mockNode)).not.toThrow();
expect(() => assertParamIsOfAnyTypes('testParam', true, types, mockNode)).toThrow(
'Parameter "testParam" must be string or number',
);
});
});
describe('Edge cases and additional scenarios', () => {
describe('validateNodeParameters edge cases', () => {
it('should handle NaN values correctly', () => {
const value = {
number: NaN,
};
const parameters = {
number: { type: 'number' as const, required: true },
};
// NaN is of type 'number' in JavaScript
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle Infinity values correctly', () => {
const value = {
number: Infinity,
};
const parameters = {
number: { type: 'number' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle mixed array types correctly', () => {
const value = {
mixed: [1, '2', 3], // Invalid: mixed types in array
};
const parameters = {
mixed: { type: 'number[]' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "mixed" does not match any of the expected types: number[]',
);
});
it('should handle nested arrays', () => {
const value = {
nested: [
[1, 2],
[3, 4],
], // Array of arrays
};
const parameters = {
nested: { type: 'object' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle resource-locator with false __rl property', () => {
const value = {
resource: {
__rl: false, // Should still be valid as it has the property
mode: 'list',
value: 'some-value',
},
};
const parameters = {
resource: { type: 'resource-locator' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle resource-locator missing __rl property', () => {
const value = {
resource: {
mode: 'list',
value: 'some-value',
// __rl is missing
},
};
const parameters = {
resource: { type: 'resource-locator' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow(
'Parameter "resource" does not match any of the expected types: resource-locator',
);
});
it('should handle empty string as valid string parameter', () => {
const value = {
name: '',
};
const parameters = {
name: { type: 'string' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle zero as valid number parameter', () => {
const value = {
count: 0,
};
const parameters = {
count: { type: 'number' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle arrays with only false values', () => {
const value = {
flags: [false, false, false],
};
const parameters = {
flags: { type: 'boolean[]' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
it('should handle three or more type unions', () => {
const value = {
multiType: 'string value',
};
const parameters = {
multiType: {
type: ['string', 'number', 'boolean'] as Array<'string' | 'number' | 'boolean'>,
required: true,
},
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
// Test with boolean value
const value2 = {
multiType: true,
};
expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow();
});
it('should handle array types in multi-type parameters', () => {
const value = {
flexParam: ['a', 'b', 'c'],
};
const parameters = {
flexParam: {
type: ['string', 'string[]'] as Array<'string' | 'string[]'>,
required: true,
},
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
// Test with single string
const value2 = {
flexParam: 'single string',
};
expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow();
});
it('should handle object with null prototype', () => {
const value = Object.create(null);
value.name = 'test';
const parameters = {
name: { type: 'string' as const, required: true },
};
expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow();
});
});
describe('assertParamIsArray edge cases', () => {
const isString = (val: unknown): val is string => typeof val === 'string';
it('should handle array-like objects', () => {
const arrayLike = { 0: 'a', 1: 'b', length: 2 };
expect(() => assertParamIsArray('testParam', arrayLike, isString, mockNode)).toThrow(
'Parameter "testParam" is not an array',
);
});
it('should handle sparse arrays', () => {
const sparse = new Array(3);
sparse[0] = 'a';
sparse[2] = 'c';
// sparse[1] is undefined
// For loop implementation properly validates sparse arrays and throws on undefined elements
expect(() => assertParamIsArray('testParam', sparse, isString, mockNode)).toThrow(
'Parameter "testParam" has elements that don\'t match expected types',
);
});
it('should handle arrays with explicit undefined values', () => {
const arrayWithUndefined = ['a', undefined, 'c'];
expect(() =>
assertParamIsArray('testParam', arrayWithUndefined, isString, mockNode),
).toThrow('Parameter "testParam" has elements that don\'t match expected types');
});
it('should handle very large arrays efficiently', () => {
const largeArray = new Array(1000).fill('test');
expect(() => assertParamIsArray('testParam', largeArray, isString, mockNode)).not.toThrow();
});
});
});
});
@@ -0,0 +1,20 @@
import { resolveRelativePath } from '../../src/node-parameters/path-utils';
describe('resolveRelativePath', () => {
test.each([
['parameters.level1.level2.field', '&childField', 'level1.level2.childField'],
['parameters.level1.level2[0].field', '&childField', 'level1.level2[0].childField'],
['parameters.level1.level2.field', 'absolute.path', 'absolute.path'],
['parameters', '&childField', 'childField'],
['parameters.level1.level2.field', '', ''],
['', '&childField', 'childField'],
['', '', ''],
['parameters.level1.level2.field', 'relative.path', 'relative.path'],
])(
'should resolve relative path for fullPath: %s and candidateRelativePath: %s',
(fullPath, candidateRelativePath, expected) => {
const result = resolveRelativePath(fullPath, candidateRelativePath);
expect(result).toBe(expected);
},
);
});
@@ -0,0 +1,788 @@
import type { INode } from '../src/interfaces';
import {
hasDotNotationBannedChar,
backslashEscape,
dollarEscape,
applyAccessPatterns,
extractReferencesInNodeExpressions,
} from '../src/node-reference-parser-utils';
const makeNode = (name: string, expressions?: string[]) =>
({
parameters: Object.fromEntries(expressions?.map((x, i) => [`p${i}`, `={{ ${x} }}`]) ?? []),
name,
}) as INode;
describe('NodeReferenceParserUtils', () => {
describe('hasDotNotationBannedChar', () => {
it('should return true for strings with banned characters', () => {
expect(hasDotNotationBannedChar('1abc')).toBe(true);
expect(hasDotNotationBannedChar('abc!')).toBe(true);
expect(hasDotNotationBannedChar('abc@')).toBe(true);
});
it('should return false for strings without banned characters', () => {
expect(hasDotNotationBannedChar('abc')).toBe(false);
expect(hasDotNotationBannedChar('validName')).toBe(false);
});
});
describe('backslashEscape', () => {
it('should escape special characters with a backslash', () => {
expect(backslashEscape('abc.def')).toBe('abc\\.def');
expect(backslashEscape('[abc]')).toBe('\\[abc\\]');
expect(backslashEscape('a+b')).toBe('a\\+b');
});
it('should return the same string if no escapable characters are present', () => {
expect(backslashEscape('abc')).toBe('abc');
});
});
describe('dollarEscape', () => {
it('should escape dollar signs with double dollar signs', () => {
expect(dollarEscape('$abc')).toBe('$$abc');
expect(dollarEscape('abc$')).toBe('abc$$');
expect(dollarEscape('$a$b$c')).toBe('$$a$$b$$c');
});
it('should return the same string if no dollar signs are present', () => {
expect(dollarEscape('abc')).toBe('abc');
});
});
describe('applyAccessPatterns', () => {
it.each([
{
expression: '$node["oldName"].data',
previousName: 'oldName',
newName: 'newName',
expected: '$node["newName"].data',
},
{
expression: '$node.oldName.data',
previousName: 'oldName',
newName: 'new.Name',
expected: '$node["new.Name"].data',
},
{
expression: '$node["someOtherName"].data',
previousName: 'oldName',
newName: 'newName',
expected: '$node["someOtherName"].data',
},
{
expression: '$node["oldName"].data + $node["oldName"].info',
previousName: 'oldName',
newName: 'newName',
expected: '$node["newName"].data + $node["newName"].info',
},
{
expression: '$items("oldName", 0)',
previousName: 'oldName',
newName: 'newName',
expected: '$items("newName", 0)',
},
{
expression: "$items('oldName', 0)",
previousName: 'oldName',
newName: 'newName',
expected: "$items('newName', 0)",
},
{
expression: "$('oldName')",
previousName: 'oldName',
newName: 'newName',
expected: "$('newName')",
},
{
expression: '$("oldName")',
previousName: 'oldName',
newName: 'newName',
expected: '$("newName")',
},
{
expression: '$node["oldName"].data + $items("oldName", 0) + $("oldName")',
previousName: 'oldName',
newName: 'newName',
expected: '$node["newName"].data + $items("newName", 0) + $("newName")',
},
{
expression: '$node["oldName"].data + $items("oldName", 0)',
previousName: 'oldName',
newName: 'new-Name',
expected: '$node["new-Name"].data + $items("new-Name", 0)',
},
{
expression: '$node["old-Name"].data + $items("old-Name", 0)',
previousName: 'old-Name',
newName: 'newName',
expected: '$node["newName"].data + $items("newName", 0)',
},
{
expression: 'someRandomExpression("oldName")',
previousName: 'oldName',
newName: 'newName',
expected: 'someRandomExpression("oldName")',
},
{
expression: '$("old\\"Name")',
previousName: 'old\\"Name',
newName: 'n\\\'ew\\"Name',
expected: '$("n\\\'ew\\"Name")',
},
])(
'should correctly transform expression "$expression" with previousName "$previousName" and newName "$newName"',
({ expression, previousName, newName, expected }) => {
const result = applyAccessPatterns(expression, previousName, newName);
expect(result).toBe(expected);
},
);
});
describe('extractReferencesInNodeExpressions', () => {
let nodes: INode[] = [];
let nodeNames: string[] = [];
let startNodeName = 'Start';
beforeEach(() => {
nodes = [
makeNode('B', ['$("A").item.json.myField']),
makeNode('C', ['$("A").first().json.myField.anotherField']),
];
nodeNames = ['A', 'B', 'C'];
startNodeName = 'Start';
});
it('should extract used expressions', () => {
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField', '$("A").item.json.myField'],
['myField_anotherField_firstItem', '$("A").first().json.myField.anotherField'],
]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: "={{ $('Start').item.json.myField }}" },
},
{
name: 'C',
parameters: { p0: "={{ $('Start').first().json.myField_anotherField_firstItem }}" },
},
]);
});
it('should handle metadata functions', () => {
nodes = [
makeNode('B', ['$("A").isExecuted ? 1 : 2']),
makeNode('C', ['someFunction($("D").params["resource"])']),
];
nodeNames = ['A', 'B', 'C', 'D'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['A_isExecuted', '$("A").isExecuted'],
['D_params', '$("D").params'],
]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: "={{ $('Start').first().json.A_isExecuted ? 1 : 2 }}" },
},
{
name: 'C',
parameters: { p0: '={{ someFunction($(\'Start\').first().json.D_params["resource"]) }}' },
},
]);
});
it('should not handle standalone node references', () => {
nodes = [makeNode('B', ['$("D")'])];
nodeNames = ['B', 'D'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
expect([...result.variables.entries()]).toEqual([]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: '={{ $("D") }}' },
},
]);
});
it('should not handle reference to non-existent node', () => {
nodes = [makeNode('B', ['$("E").item.json.x'])];
nodeNames = ['B'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
expect([...result.variables.entries()]).toEqual([]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: '={{ $("E").item.json.x }}' },
},
]);
});
it('should not handle invalid node references', () => {
nodes = [makeNode('B', ['$("D)'])];
nodeNames = ['B', 'D'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: '={{ $("D) }}' },
},
]);
});
it('should not handle new fields on the node', () => {
nodes = [makeNode('B', ['$("D").thisIsNotAField.json.x.y.z'])];
nodeNames = ['B', 'D'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: '={{ $("D").thisIsNotAField.json.x.y.z }}' },
},
]);
});
it('should handle $json in graphInputNodeName only', () => {
nodes = [makeNode('B', ['$json.a.b.c_d["e"]["f"]']), makeNode('C', ['$json.x.y.z'])];
nodeNames = ['A', 'B', 'C'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
expect([...result.variables.entries()]).toEqual([['a_b_c_d', '$json.a.b.c_d']]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: '={{ $json.a_b_c_d["e"]["f"] }}' },
},
{
name: 'C',
parameters: { p0: '={{ $json.x.y.z }}' },
},
]);
});
it('should handle complex $json case for first node', () => {
nodes = [
{
parameters: {
p0: '=https://raw.githubusercontent.com/{{ $json.org }}/{{ $json.repo }}/refs/heads/master/package.json',
},
name: 'A',
} as unknown as INode,
];
nodeNames = ['A', 'B'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['A']);
expect([...result.variables.entries()]).toEqual([
['repo', '$json.repo'],
['org', '$json.org'],
]);
expect(result.nodes).toEqual([
{
name: 'A',
parameters: {
p0: '=https://raw.githubusercontent.com/{{ $json.org }}/{{ $json.repo }}/refs/heads/master/package.json',
},
},
]);
});
it('should support different node accessor patterns', () => {
nodes = [
makeNode('N', ['$("A").item.json.myField']),
makeNode('O', ['$node["B"].item.json.myField']),
makeNode('P', ['$node.C.item.json.myField']),
];
nodeNames = ['A', 'B', 'C', 'N', 'O', 'P'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField', '$("A").item.json.myField'],
['B_myField', '$node["B"].item.json.myField'],
['C_myField', '$node.C.item.json.myField'],
]);
expect(result.nodes).toEqual([
{
name: 'N',
parameters: { p0: "={{ $('Start').item.json.myField }}" },
},
{
name: 'O',
parameters: { p0: "={{ $('Start').item.json.B_myField }}" },
},
{
name: 'P',
parameters: { p0: "={{ $('Start').item.json.C_myField }}" },
},
]);
});
it('should handle simple name clashes', () => {
nodes = [
makeNode('B', ['$("A").item.json.myField']),
makeNode('C', ['$("D").item.json.myField']),
makeNode('E', ['$("F").item.json.myField']),
];
nodeNames = ['A', 'B', 'C', 'D', 'E', 'F'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField', '$("A").item.json.myField'],
['D_myField', '$("D").item.json.myField'],
['F_myField', '$("F").item.json.myField'],
]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: "={{ $('Start').item.json.myField }}" },
},
{
name: 'C',
parameters: { p0: "={{ $('Start').item.json.D_myField }}" },
},
{
name: 'E',
parameters: { p0: "={{ $('Start').item.json.F_myField }}" },
},
]);
});
it('should handle complex name clashes', () => {
nodes = [
makeNode('F', ['$("A").item.json.myField']),
makeNode('B', ['$("A").item.json.Node_Name_With_Gap_myField']),
makeNode('C', ['$("D").item.json.Node_Name_With_Gap_myField']),
makeNode('E', ['$("Node_Name_With_Gap").item.json.myField']),
];
nodeNames = ['A', 'B', 'C', 'D', 'E', 'F', 'Node_Name_With_Gap'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField', '$("A").item.json.myField'],
['Node_Name_With_Gap_myField', '$("A").item.json.Node_Name_With_Gap_myField'],
['D_Node_Name_With_Gap_myField', '$("D").item.json.Node_Name_With_Gap_myField'],
// This is the `myField` variable from node 'E', referencing $("Node_Name_With_Gap").item.json.myField
// It first has a clash with A.myField, requiring its node name to come attached
// And then has _1 because it clashes B.Node_Name_With_Gap_myField
['Node_Name_With_Gap_myField_1', '$("Node_Name_With_Gap").item.json.myField'],
]);
expect(result.nodes).toEqual([
{ name: 'F', parameters: { p0: "={{ $('Start').item.json.myField }}" } },
{
name: 'B',
parameters: { p0: "={{ $('Start').item.json.Node_Name_With_Gap_myField }}" },
},
{
name: 'C',
parameters: { p0: "={{ $('Start').item.json.D_Node_Name_With_Gap_myField }}" },
},
{
name: 'E',
parameters: { p0: "={{ $('Start').item.json.Node_Name_With_Gap_myField_1 }}" },
},
]);
});
it('should handle code node', () => {
nodes = [
{
parameters: {
jsCode:
"for (const item of $input.all()) {\n item.json.myNewField = $('DebugHelper').first().json.uid;\n}\n\nreturn $input.all();",
},
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [660, 0],
id: 'c9de02d0-982a-4f8c-9af7-93f63795aa9b',
name: 'Code',
},
];
nodeNames = ['DebugHelper', 'Code'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['uid_firstItem', "$('DebugHelper').first().json.uid"],
]);
expect(result.nodes).toEqual([
{
parameters: {
jsCode:
"for (const item of $input.all()) {\n item.json.myNewField = $('Start').first().json.uid_firstItem;\n}\n\nreturn $input.all();",
},
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [660, 0],
id: 'c9de02d0-982a-4f8c-9af7-93f63795aa9b',
name: 'Code',
},
]);
});
it('should not extract expression referencing node in subGraph', () => {
nodes = [
makeNode('B', ['$("A").item.json.myField']),
makeNode('C', ['$("B").first().json.myField.anotherField']),
];
nodeNames = ['A', 'B', 'C'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([['myField', '$("A").item.json.myField']]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: { p0: "={{ $('Start').item.json.myField }}" },
},
{
name: 'C',
parameters: { p0: '={{ $("B").first().json.myField.anotherField }}' },
},
]);
});
it('should throw if node name clashes with start name', () => {
nodes = [makeNode('Start', ['$("A").item.json.myField'])];
nodeNames = ['A', 'Start'];
expect(() => extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName)).toThrow();
});
it('should support custom Start node name', () => {
nodes = [makeNode('Start', ['$("A").item.json.myField'])];
nodeNames = ['A', 'Start'];
startNodeName = 'A different start name';
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([['myField', '$("A").item.json.myField']]);
expect(result.nodes).toEqual([
{
name: 'Start',
parameters: { p0: "={{ $('A different start name').item.json.myField }}" },
},
]);
});
it('should throw if called with node in subgraph whose name is not in nodeNames list', () => {
nodes = [makeNode('B', ['$("A").item.json.myField'])];
nodeNames = ['A'];
expect(() => extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName)).toThrow();
});
it('handles multiple expressions referencing different nodes in the same string', () => {
nodes = [makeNode('B', ['$("A").item.json.myField + $("C").item.json.anotherField'])];
nodeNames = ['A', 'B', 'C'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['anotherField', '$("C").item.json.anotherField'],
['myField', '$("A").item.json.myField'],
]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: {
p0: "={{ $('Start').item.json.myField + $('Start').item.json.anotherField }}",
},
},
]);
});
it('handles multiple expressions referencing different nested bits of the same field', () => {
nodes = [
makeNode('B', [
'$("A").item.json.myField.nestedField',
'$("A").item.json.myField.anotherNestedField',
'$("A").item.json.myField.anotherNestedField.x.y.z',
]),
];
nodeNames = ['A', 'B'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField_nestedField', '$("A").item.json.myField.nestedField'],
['myField_anotherNestedField', '$("A").item.json.myField.anotherNestedField'],
['myField_anotherNestedField_x_y_z', '$("A").item.json.myField.anotherNestedField.x.y.z'],
]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: {
p0: "={{ $('Start').item.json.myField_nestedField }}",
p1: "={{ $('Start').item.json.myField_anotherNestedField }}",
p2: "={{ $('Start').item.json.myField_anotherNestedField_x_y_z }}",
},
},
]);
});
it('handles first(), last(), all() and items at the same time', () => {
nodes = [
makeNode('B', [
'$("A").first().json.myField',
'$("A").last().json.myField',
'$("A").all().json.myField',
'$("A").item.json.myField',
'$("A").first()',
'$("A").all()',
]),
];
nodeNames = ['A', 'B'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField_firstItem', '$("A").first().json.myField'],
['myField_lastItem', '$("A").last().json.myField'],
['myField_allItems', '$("A").all().json.myField'],
['myField', '$("A").item.json.myField'],
['A_firstItem', '$("A").first()'],
['A_allItems', '$("A").all()'],
]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: {
p0: "={{ $('Start').first().json.myField_firstItem }}",
p1: "={{ $('Start').last().json.myField_lastItem }}",
p2: "={{ $('Start').first().json.myField_allItems }}",
p3: "={{ $('Start').item.json.myField }}",
p4: "={{ $('Start').first().json.A_firstItem }}",
p5: "={{ $('Start').first().json.A_allItems }}",
},
},
]);
});
it('handles supported itemMatching examples', () => {
nodes = [
makeNode('B', [
'$("A").itemMatching(0).json.myField',
'$("A").itemMatching(1).json.myField',
'$("C").itemMatching(1).json.myField',
'$("A").itemMatching(20).json.myField',
]),
];
nodeNames = ['A', 'B', 'C'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField_itemMatching_0', '$("A").itemMatching(0).json.myField'],
['myField_itemMatching_1', '$("A").itemMatching(1).json.myField'],
['C_myField_itemMatching_1', '$("C").itemMatching(1).json.myField'],
['myField_itemMatching_20', '$("A").itemMatching(20).json.myField'],
]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: {
p0: "={{ $('Start').itemMatching(0).json.myField_itemMatching_0 }}",
p1: "={{ $('Start').itemMatching(1).json.myField_itemMatching_1 }}",
p2: "={{ $('Start').itemMatching(1).json.C_myField_itemMatching_1 }}",
p3: "={{ $('Start').itemMatching(20).json.myField_itemMatching_20 }}",
},
},
]);
});
it('does not throw for complex itemMatching example', () => {
nodes = [
makeNode('B', [
'$("A").itemMatching(Math.PI).json.myField',
'$("A").itemMatching(eval("const fib = (n) => n < 2 ? 1 : (fib(n - 1) + fib(n-2)); fib(15)")).json.anotherField',
'$("A").itemMatching($("A").itemMatch(1).json.myField).json.myField',
]),
];
nodeNames = ['A', 'B'];
expect(() =>
extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName),
).not.toThrow();
});
it('should handle multiple expressions', () => {
nodes = [
makeNode('B', ['$("A").item.json.myField', '$("C").item.json.anotherField']),
makeNode('D', ['$("A").item.json.myField', '$("B").item.json.someField']),
];
nodeNames = ['A', 'B', 'C', 'D'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField', '$("A").item.json.myField'],
['anotherField', '$("C").item.json.anotherField'],
]);
expect(result.nodes).toEqual([
{
name: 'B',
parameters: {
p0: "={{ $('Start').item.json.myField }}",
p1: "={{ $('Start').item.json.anotherField }}",
},
},
{
name: 'D',
parameters: {
p0: "={{ $('Start').item.json.myField }}",
p1: '={{ $("B").item.json.someField }}',
},
},
]);
});
it('should support handle calls to normal js functions on the data accessor', () => {
nodes = [makeNode('A', ['$("B B").first().toJsonObject().randomJSFunction()'])];
nodeNames = ['A', 'B B'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([['B_B_firstItem', '$("B B").first()']]);
expect(result.nodes).toEqual([
{
name: 'A',
parameters: {
p0: "={{ $('Start').first().json.B_B_firstItem.toJsonObject().randomJSFunction() }}",
},
},
]);
});
it('should support handle spaces and special characters in nodeNames', () => {
nodes = [
makeNode('a_=-9-0!@#!%^$%&*(', ['$("A").item.json.myField']),
makeNode('A node with spaces', [
'$("A \\" |[w.e,i,r$d]| `\' Ñode \\$\\( Name \\)").item.json.myField',
]),
];
nodeNames = [
'A',
'A node with spaces',
'A \\" |[w.e,i,r$d]| `\' Ñode \\$\\( Name \\)',
'a_=-9-0!@#!%^$%&*(',
];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([
['myField', '$("A").item.json.myField'],
[
'A__weir$d__ode__$_Name__myField',
'$("A \\" |[w.e,i,r$d]| `\' Ñode \\$\\( Name \\)").item.json.myField',
],
]);
expect(result.nodes).toEqual([
{
name: 'a_=-9-0!@#!%^$%&*(',
parameters: { p0: "={{ $('Start').item.json.myField }}" },
},
{
name: 'A node with spaces',
parameters: { p0: "={{ $('Start').item.json.A__weir$d__ode__$_Name__myField }}" },
},
]);
});
it('should handle assignments format of Set node correctly', () => {
nodes = [
{
parameters: {
assignments: {
assignments: [
{
id: 'cf8bd6cb-f28a-4a73-b141-02e5c22cfe74',
name: 'ghApiBaseUrl',
value: '={{ $("A").item.json.x.y.z }}',
type: 'string',
},
],
},
options: {},
},
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [80, 80],
id: '6e2fd284-2aba-4dee-8921-18be9a291484',
name: 'Params',
},
];
nodeNames = ['A', 'Params'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([['x_y_z', '$("A").item.json.x.y.z']]);
expect(result.nodes).toEqual([
{
parameters: {
assignments: {
assignments: [
{
id: 'cf8bd6cb-f28a-4a73-b141-02e5c22cfe74',
name: 'ghApiBaseUrl',
value: "={{ $('Start').item.json.x_y_z }}",
type: 'string',
},
],
},
options: {},
},
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [80, 80],
id: '6e2fd284-2aba-4dee-8921-18be9a291484',
name: 'Params',
},
]);
});
it('should support handle unexpected code after the data accessor', () => {
nodes = [makeNode('A', ['$("B").all()[0].json.first_node_variable'])];
nodeNames = ['A', 'B'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([['B_allItems', '$("B").all()']]);
expect(result.nodes).toEqual([
{
name: 'A',
parameters: {
p0: "={{ $('Start').first().json.B_allItems[0].json.first_node_variable }}",
},
},
]);
});
it('should carry over unrelated properties', () => {
nodes = [
{
parameters: {
a: 3,
b: { c: 4, d: true },
d: 'hello',
e: "={{ $('goodbye').item.json.f }}",
},
name: 'A',
} as unknown as INode,
];
nodeNames = ['A', 'goodbye'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
expect([...result.variables.entries()]).toEqual([['f', "$('goodbye').item.json.f"]]);
expect(result.nodes).toEqual([
{
parameters: {
a: 3,
b: { c: 4, d: true },
d: 'hello',
e: "={{ $('Start').item.json.f }}",
},
name: 'A',
},
]);
});
it('should extract "fieldToSplitOut" constant fields in n8n-nodes-base.splitOut', () => {
nodes = [
{
parameters: {
fieldToSplitOut: 'foo,bar',
},
type: 'n8n-nodes-base.splitOut',
typeVersion: 1,
position: [200, 200],
id: 'splitOutNodeId',
name: 'A',
},
];
nodeNames = ['A', 'B'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['A']);
expect([...result.variables.entries()]).toEqual([
['foo', '$json.foo'],
['bar', '$json.bar'],
]);
});
it('should error at extracting "fieldToSplitOut" expression in n8n-nodes-base.splitOut', () => {
nodes = [
{
parameters: {
fieldToSplitOut: '={{ foo,bar }}',
},
type: 'n8n-nodes-base.splitOut',
typeVersion: 1,
position: [200, 200],
id: 'splitOutNodeId',
name: 'A',
},
];
nodeNames = ['A', 'B'];
expect(() =>
extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['A']),
).toThrow('not supported');
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,371 @@
import type { INode, INodeType, IConnections, INodeTypeDescription } from '../src/interfaces';
import {
validateNodeCredentials,
isNodeConnected,
isTriggerLikeNode,
type NodeCredentialIssue,
} from '../src/node-validation';
describe('node-validation', () => {
describe('validateNodeCredentials', () => {
const createNode = (
credentials?: Record<string, { id: string }>,
parameters?: Record<string, unknown>,
): INode => ({
name: 'Test Node',
type: 'n8n-nodes-base.test',
id: 'node-1',
typeVersion: 1,
position: [0, 0],
credentials: credentials as INode['credentials'],
parameters: (parameters || {}) as INode['parameters'],
});
const createNodeType = (credentials?: INodeTypeDescription['credentials']): INodeType => ({
description: {
displayName: 'Test Node',
name: 'test',
group: ['transform'],
version: 1,
description: 'Test node',
defaults: { name: 'Test Node' },
inputs: ['main'],
outputs: ['main'],
properties: [],
credentials: credentials || [],
},
});
it('should return no issues when node has all required credentials', () => {
const node = createNode({ testCredential: { id: 'cred-1' } });
const nodeType = createNodeType([
{
name: 'testCredential',
displayName: 'Test Credential',
required: true,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toEqual([]);
});
it('should return missing issue when required credential is not set', () => {
const node = createNode();
const nodeType = createNodeType([
{
name: 'testCredential',
displayName: 'Test Credential',
required: true,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toEqual([
{
type: 'missing',
displayName: 'Test Credential',
credentialName: 'testCredential',
},
]);
});
it('should return not-configured issue when credential has no ID', () => {
const node = createNode({ testCredential: { id: '' } });
const nodeType = createNodeType([
{
name: 'testCredential',
displayName: 'Test Credential',
required: true,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toEqual([
{
type: 'not-configured',
displayName: 'Test Credential',
credentialName: 'testCredential',
},
]);
});
it('should skip optional credentials', () => {
const node = createNode();
const nodeType = createNodeType([
{
name: 'optionalCredential',
displayName: 'Optional Credential',
required: false,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toEqual([]);
});
it('should respect displayOptions and skip hidden credentials', () => {
const node = createNode(undefined, { authentication: 'none' });
const nodeType = createNodeType([
{
name: 'basicAuth',
displayName: 'Basic Auth',
required: true,
displayOptions: {
show: {
authentication: ['basicAuth'],
},
},
},
]);
const issues = validateNodeCredentials(node, nodeType);
// Should be empty because basicAuth is hidden when authentication='none'
expect(issues).toEqual([]);
});
it('should validate credentials when displayOptions match', () => {
const node = createNode(undefined, { authentication: 'basicAuth' });
const nodeType = createNodeType([
{
name: 'basicAuth',
displayName: 'Basic Auth',
required: true,
displayOptions: {
show: {
authentication: ['basicAuth'],
},
},
},
]);
const issues = validateNodeCredentials(node, nodeType);
// Should have issue because basicAuth is shown but not set
expect(issues).toEqual([
{
type: 'missing',
displayName: 'Basic Auth',
credentialName: 'basicAuth',
},
]);
});
it('should return multiple issues for multiple missing credentials', () => {
const node = createNode();
const nodeType = createNodeType([
{
name: 'credential1',
displayName: 'Credential 1',
required: true,
},
{
name: 'credential2',
displayName: 'Credential 2',
required: true,
},
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues).toHaveLength(2);
expect(issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ credentialName: 'credential1' }),
expect.objectContaining({ credentialName: 'credential2' }),
]),
);
});
it('should use credential name as displayName fallback', () => {
const node = createNode();
const nodeType = createNodeType([
{
name: 'testCredential',
required: true,
// No displayName provided
} as any,
]);
const issues = validateNodeCredentials(node, nodeType);
expect(issues[0].displayName).toBe('testCredential');
});
});
describe('isNodeConnected', () => {
it('should return true when node has outgoing connections', () => {
const connections: IConnections = {
'Node A': {
main: [[{ node: 'Node B', type: 'main', index: 0 }]],
},
};
const connectionsByDestination: IConnections = {};
const result = isNodeConnected('Node A', connections, connectionsByDestination);
expect(result).toBe(true);
});
it('should return true when node has incoming connections', () => {
const connections: IConnections = {};
const connectionsByDestination: IConnections = {
'Node B': {
main: [[{ node: 'Node A', type: 'main', index: 0 }]],
},
};
const result = isNodeConnected('Node B', connections, connectionsByDestination);
expect(result).toBe(true);
});
it('should return true when node has both incoming and outgoing connections', () => {
const connections: IConnections = {
'Node B': {
main: [[{ node: 'Node C', type: 'main', index: 0 }]],
},
};
const connectionsByDestination: IConnections = {
'Node B': {
main: [[{ node: 'Node A', type: 'main', index: 0 }]],
},
};
const result = isNodeConnected('Node B', connections, connectionsByDestination);
expect(result).toBe(true);
});
it('should return false when node has no connections', () => {
const connections: IConnections = {
'Node A': {
main: [[{ node: 'Node B', type: 'main', index: 0 }]],
},
};
const connectionsByDestination: IConnections = {};
const result = isNodeConnected('Node C', connections, connectionsByDestination);
expect(result).toBe(false);
});
it('should return false when node exists but has empty connections', () => {
const connections: IConnections = {
'Node A': {},
};
const connectionsByDestination: IConnections = {
'Node A': {},
};
const result = isNodeConnected('Node A', connections, connectionsByDestination);
expect(result).toBe(false);
});
});
describe('isTriggerLikeNode', () => {
it('should return true for node with trigger function', () => {
const nodeType: INodeType = {
description: {
displayName: 'Trigger Node',
name: 'trigger',
group: ['trigger'],
version: 1,
description: 'Test trigger',
defaults: { name: 'Trigger' },
inputs: [],
outputs: ['main'],
properties: [],
},
trigger: async () => ({
closeFunction: async () => {},
manualTriggerFunction: async () => {},
}),
};
expect(isTriggerLikeNode(nodeType)).toBe(true);
});
it('should return true for node with webhook function', () => {
const nodeType: INodeType = {
description: {
displayName: 'Webhook Node',
name: 'webhook',
group: ['trigger'],
version: 1,
description: 'Test webhook',
defaults: { name: 'Webhook' },
inputs: [],
outputs: ['main'],
properties: [],
},
webhook: async () => ({ workflowData: [[]] }),
};
expect(isTriggerLikeNode(nodeType)).toBe(true);
});
it('should return true for node with poll function', () => {
const nodeType: INodeType = {
description: {
displayName: 'Poll Node',
name: 'poll',
group: ['trigger'],
version: 1,
description: 'Test poll',
defaults: { name: 'Poll' },
inputs: [],
outputs: ['main'],
properties: [],
},
poll: async () => [[]],
};
expect(isTriggerLikeNode(nodeType)).toBe(true);
});
it('should return false for regular node', () => {
const nodeType: INodeType = {
description: {
displayName: 'Regular Node',
name: 'regular',
group: ['transform'],
version: 1,
description: 'Test regular node',
defaults: { name: 'Regular' },
inputs: ['main'],
outputs: ['main'],
properties: [],
},
execute: async () => [[]],
};
expect(isTriggerLikeNode(nodeType)).toBe(false);
});
it('should return false for node with only execute function', () => {
const nodeType: INodeType = {
description: {
displayName: 'Execute Node',
name: 'execute',
group: ['transform'],
version: 1,
description: 'Test execute node',
defaults: { name: 'Execute' },
inputs: ['main'],
outputs: ['main'],
properties: [],
},
execute: async () => [[]],
};
expect(isTriggerLikeNode(nodeType)).toBe(false);
});
});
});
@@ -0,0 +1,172 @@
import type { IDataObject } from '../src/interfaces';
import * as ObservableObject from '../src/observable-object';
describe('ObservableObject', () => {
test('should recognize that item on parent level got added (init empty)', () => {
const testObject = ObservableObject.create({});
expect(testObject.__dataChanged).toBeFalsy();
testObject.a = {};
expect(testObject.__dataChanged).toBeTruthy();
// Make sure that "__dataChanged" does not returned as a key
expect(Object.keys(testObject)).toEqual(['a']);
});
test('should not recognize that item on parent level changed if it is empty object and option "ignoreEmptyOnFirstChild" === true (init empty)', () => {
const testObject = ObservableObject.create({}, undefined, { ignoreEmptyOnFirstChild: true });
expect(testObject.__dataChanged).toBeFalsy();
testObject.a = {};
expect(testObject.__dataChanged).toBeFalsy();
expect(testObject.a).toEqual({});
});
test('should recognize that item on parent level changed if it is not empty object and option "ignoreEmptyOnFirstChild" === true (init empty)', () => {
const testObject = ObservableObject.create({}, undefined, { ignoreEmptyOnFirstChild: true });
expect(testObject.__dataChanged).toBeFalsy();
testObject.a = { b: 2 };
expect(testObject.__dataChanged).toBeTruthy();
expect(testObject.a).toEqual({ b: 2 });
});
test('should not recognize that item on parent level changed if it is empty array and option "ignoreEmptyOnFirstChild" === true (init empty)', () => {
const testObject = ObservableObject.create({}, undefined, { ignoreEmptyOnFirstChild: true });
expect(testObject.__dataChanged).toBeFalsy();
testObject.a = [];
expect(testObject.__dataChanged).toBeFalsy();
expect(testObject.a).toEqual([]);
});
test('should recognize that item on parent level changed if it is not empty []] and option "ignoreEmptyOnFirstChild" === true (init empty)', () => {
const testObject = ObservableObject.create({}, undefined, { ignoreEmptyOnFirstChild: true });
expect(testObject.__dataChanged).toBeFalsy();
testObject.a = [1, 2];
expect(testObject.__dataChanged).toBeTruthy();
expect(testObject.a).toEqual([1, 2]);
});
test('should recognize that item on parent level changed (init data exists)', () => {
const testObject = ObservableObject.create({ a: 1 });
expect(testObject.__dataChanged).toBeFalsy();
expect(testObject.a).toEqual(1);
testObject.a = 2;
expect(testObject.__dataChanged).toBeTruthy();
expect(testObject.a).toEqual(2);
});
test('should recognize that array on parent level changed (init data exists)', () => {
const testObject = ObservableObject.create({ a: [1, 2] });
expect(testObject.__dataChanged).toBeFalsy();
expect(testObject.a).toEqual([1, 2]);
(testObject.a as number[]).push(3);
expect(testObject.__dataChanged).toBeTruthy();
expect(testObject.a).toEqual([1, 2, 3]);
});
test('should recognize that item on first child level changed (init data exists)', () => {
const testObject = ObservableObject.create({ a: { b: 1 } });
expect(testObject.__dataChanged).toBeFalsy();
expect((testObject.a! as IDataObject).b).toEqual(1);
(testObject.a! as IDataObject).b = 2;
expect(testObject.__dataChanged).toBeTruthy();
expect((testObject.a! as IDataObject).b).toEqual(2);
});
test('should recognize that item on first child level changed if it is now empty and option "ignoreEmptyOnFirstChild" === true (init data exists)', () => {
const testObject = ObservableObject.create({ a: { b: 1 } }, undefined, {
ignoreEmptyOnFirstChild: true,
});
expect(testObject.__dataChanged).toBeFalsy();
expect((testObject.a! as IDataObject).b).toEqual(1);
testObject.a = {};
expect(testObject.__dataChanged).toBeTruthy();
expect(testObject.a).toEqual({});
});
test('should recognize that item on first child level changed if it is now empty and option "ignoreEmptyOnFirstChild" === false (init data exists)', () => {
const testObject = ObservableObject.create({ a: { b: 1 } }, undefined, {
ignoreEmptyOnFirstChild: false,
});
expect(testObject.__dataChanged).toBeFalsy();
expect((testObject.a! as IDataObject).b).toEqual(1);
testObject.a = {};
expect(testObject.__dataChanged).toBeTruthy();
expect(testObject.a).toEqual({});
});
test('should recognize that array on first child level changed (init data exists)', () => {
const testObject = ObservableObject.create({ a: { b: [1, 2] } });
expect(testObject.__dataChanged).toBeFalsy();
expect((testObject.a! as IDataObject).b).toEqual([1, 2]);
((testObject.a! as IDataObject).b as number[]).push(3);
expect(testObject.__dataChanged).toBeTruthy();
expect((testObject.a! as IDataObject).b).toEqual([1, 2, 3]);
});
test('should recognize that item on second child level changed (init data exists)', () => {
const testObject = ObservableObject.create({ a: { b: { c: 1 } } });
expect(testObject.__dataChanged).toBeFalsy();
expect((testObject.a! as IDataObject).b).toEqual({ c: 1 });
expect(((testObject.a! as IDataObject).b! as IDataObject).c).toEqual(1);
((testObject.a! as IDataObject).b! as IDataObject).c = 2;
expect(testObject.__dataChanged).toBeTruthy();
expect((testObject.a! as IDataObject).b).toEqual({ c: 2 });
});
test('should recognize that item on parent level got deleted (init data exists)', () => {
const testObject = ObservableObject.create({ a: 1 });
expect(testObject.__dataChanged).toBeFalsy();
expect(testObject.a!).toEqual(1);
delete testObject.a;
expect(testObject.__dataChanged).toBeTruthy();
expect(testObject.a!).toEqual(undefined);
expect(testObject).toEqual({});
});
test('should recognize that item on parent level got deleted even with and option "ignoreEmptyOnFirstChild" === true (init data exists)', () => {
const testObject = ObservableObject.create({ a: 1 }, undefined, {
ignoreEmptyOnFirstChild: true,
});
expect(testObject.__dataChanged).toBeFalsy();
expect(testObject.a!).toEqual(1);
delete testObject.a;
expect(testObject.__dataChanged).toBeTruthy();
expect(testObject.a!).toEqual(undefined);
expect(testObject).toEqual({});
});
test('should recognize that item on second child level got deleted (init data exists)', () => {
const testObject = ObservableObject.create({ a: { b: { c: 1 } } });
expect(testObject.__dataChanged).toBeFalsy();
expect((testObject.a! as IDataObject).b).toEqual({ c: 1 });
delete (testObject.a! as IDataObject).b;
expect(testObject.__dataChanged).toBeTruthy();
expect((testObject.a! as IDataObject).b).toEqual(undefined);
expect(testObject).toEqual({ a: {} });
});
test('should recognize that item on second child level changed with null (init data exists)', () => {
const testObject = ObservableObject.create({ a: { b: { c: null } } });
expect(testObject.__dataChanged).toBeFalsy();
expect((testObject.a! as IDataObject).b).toEqual({ c: null });
expect(((testObject.a! as IDataObject).b! as IDataObject).c).toEqual(null);
((testObject.a! as IDataObject).b! as IDataObject).c = 2;
expect(testObject.__dataChanged).toBeTruthy();
expect((testObject.a! as IDataObject).b).toEqual({ c: 2 });
});
// test('xxxxxx', () => {
// const testObject = ObservableObject.create({ a: { } }, undefined, { ignoreEmptyOnFirstChild: true });
// expect(testObject.__dataChanged).toBeFalsy();
// expect(testObject).toEqual({ a: { b: { c: 1 } } });
// ((testObject.a! as DataObject).b as DataObject).c = 2;
// // expect((testObject.a! as DataObject).b).toEqual({ c: 1 });
// expect(testObject.__dataChanged).toBeTruthy();
// // expect(testObject.a).toEqual({});
// // expect((testObject.a! as DataObject).b).toEqual({ c: 1 });
// // expect(((testObject.a! as DataObject).b! as DataObject).c).toEqual(1);
// // ((testObject.a! as DataObject).b! as DataObject).c = 2;
// // expect((testObject.a! as DataObject).b).toEqual({ c: 2 });
// });
});
@@ -0,0 +1,54 @@
import { mockFn } from 'vitest-mock-extended';
import type { INode } from '../src/index';
import { renameFormFields } from '../src/node-parameters/rename-node-utils';
const makeNode = (formFieldValues: Array<Record<string, unknown>>) =>
({
parameters: {
formFields: {
values: formFieldValues,
},
},
}) as unknown as INode;
const mockMapping = mockFn();
describe('renameFormFields', () => {
beforeEach(() => {
mockMapping.mockReset();
});
it.each([
{ parameters: {} },
{ parameters: { otherField: null } },
{ parameters: { formFields: 'a' } },
{ parameters: { formFields: { values: 3 } } },
{ parameters: { formFields: { values: { newKey: true } } } },
{ parameters: { formFields: { values: [] } } },
{ parameters: { formFields: { values: [{ fieldType: 'json' }] } } },
{ parameters: { formFields: { values: [{ fieldType: 'html' }] } } },
] as unknown as INode[])('should not modify %s without formFields.values parameters', (node) => {
renameFormFields(node, mockMapping);
expect(mockMapping).not.toBeCalled();
});
it('should rename fields based on the provided mapping', () => {
const node = makeNode([{ fieldType: 'html', html: 'some text' }]);
renameFormFields(node, mockMapping);
expect(mockMapping).toBeCalledWith('some text');
});
it('should rename multiple fields', () => {
const node = makeNode([
{ fieldType: 'html', html: 'some text' },
{ fieldType: 'html', html: 'some text' },
{ fieldType: 'html', html: 'some text' },
{ fieldType: 'html', html: 'some text' },
{ fieldType: 'html', html: 'some text' },
]);
renameFormFields(node, mockMapping);
expect(mockMapping).toBeCalledTimes(5);
});
});
@@ -0,0 +1,173 @@
import { mock } from 'vitest-mock-extended';
import type { INode, ExecutionError } from '../src/interfaces';
import {
createRunExecutionData,
createEmptyRunExecutionData,
createErrorExecutionData,
type CreateFullRunExecutionDataOptions,
} from '../src/run-execution-data-factory';
describe('RunExecutionDataFactory', () => {
describe('createRunExecutionData', () => {
it('should create a complete IRunExecutionData object with default values', () => {
const result = createRunExecutionData();
expect(result).toEqual({
version: 1,
startData: {},
manualData: undefined,
parentExecution: undefined,
pushRef: undefined,
validateSignature: undefined,
waitTill: undefined,
resultData: {
error: undefined,
runData: {},
pinData: undefined,
lastNodeExecuted: undefined,
metadata: undefined,
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
});
});
it('should create a complete IRunExecutionData object with custom options', () => {
const options = {
startData: {
startNodes: [{ name: 'Start', sourceData: { previousNode: 'Previous' } }],
destinationNode: { nodeName: 'End', mode: 'inclusive' },
},
resultData: {
runData: { testNode: [] },
lastNodeExecuted: 'testNode',
},
executionData: {
nodeExecutionStack: [{ node: {} as INode, data: {}, source: null }],
runtimeData: {
version: 1 as const,
establishedAt: 1234567890,
source: 'webhook' as const,
credentials: 'test-credentials',
},
},
parentExecution: {
executionId: 'parent-123',
workflowId: 'workflow-456',
},
validateSignature: true,
waitTill: new Date('2023-01-01'),
} satisfies CreateFullRunExecutionDataOptions;
const result = createRunExecutionData(options);
expect(result.startData).toEqual(options.startData);
expect(result.resultData.runData).toEqual(options.resultData.runData);
expect(result.resultData.lastNodeExecuted).toEqual(options.resultData.lastNodeExecuted);
expect(result.executionData?.nodeExecutionStack).toEqual(
options.executionData.nodeExecutionStack,
);
expect(result.executionData?.runtimeData).toEqual(options.executionData.runtimeData);
expect(result.parentExecution).toEqual(options.parentExecution);
expect(result.validateSignature).toBe(true);
expect(result.waitTill).toEqual(options.waitTill);
});
it('should omit `executionData` if null is passed', () => {
const result = createRunExecutionData({
executionData: null,
});
expect(result.executionData).toBeUndefined();
expect(result.startData).toEqual({});
expect(result.resultData.runData).toEqual({});
});
it('should omit `resultData.runData` if null is passed', () => {
const result = createRunExecutionData({
resultData: {
runData: null,
},
});
expect(result.resultData.runData).toBeUndefined();
expect(result.startData).toEqual({});
expect(result.executionData).toEqual({
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
});
});
});
describe('createMinimalRunExecutionData', () => {
it('should create a minimal IRunExecutionData object with empty runData', () => {
const result = createEmptyRunExecutionData();
expect(result).toEqual({
version: 1,
resultData: {
runData: {},
},
});
});
});
describe('createErrorExecutionData', () => {
it('should create a IRunExecutionData object for error execution', () => {
const node: INode = {
id: 'node-123',
name: 'TestNode',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const error = mock<ExecutionError>({
message: 'Test error occurred',
name: 'TestError',
});
const result = createErrorExecutionData(node, error);
expect(result.startData?.destinationNode).toEqual({
nodeName: 'TestNode',
mode: 'inclusive',
});
expect(result.startData?.runNodeFilter).toEqual(['TestNode']);
expect(result.executionData?.contextData).toEqual({});
expect(result.executionData?.metadata).toEqual({});
expect(result.executionData?.waitingExecution).toEqual({});
expect(result.executionData?.waitingExecutionSource).toEqual({});
expect(result.executionData?.nodeExecutionStack).toHaveLength(1);
expect(result.executionData?.nodeExecutionStack?.[0]?.node).toBe(node);
expect(result.executionData?.nodeExecutionStack?.[0]?.data.main).toEqual([
[{ json: {}, pairedItem: { item: 0 } }],
]);
expect(result.executionData?.nodeExecutionStack?.[0]?.source).toBe(null);
expect(result.resultData.runData['TestNode']).toHaveLength(1);
expect(result.resultData.runData['TestNode'][0]).toEqual({
startTime: 0,
executionIndex: 0,
executionTime: 0,
error,
source: [],
});
expect(result.resultData.error).toBe(error);
expect(result.resultData.lastNodeExecuted).toBe('TestNode');
});
});
});
@@ -0,0 +1,85 @@
import { migrateRunExecutionData } from '../../src/run-execution-data/run-execution-data';
import type { IRunExecutionDataV0 } from '../../src/run-execution-data/run-execution-data.v0';
import type { IRunExecutionDataV1 } from '../../src/run-execution-data/run-execution-data.v1';
describe('migrateRunExecutionData', () => {
it('should migrate IRunExecutionDataV0 to V1', () => {
const v0Data: IRunExecutionDataV0 = {
version: 0,
startData: {
startNodes: [],
destinationNode: 'TestNode',
originalDestinationNode: 'OriginalTestNode',
runNodeFilter: ['filter1'],
},
resultData: {
runData: {},
lastNodeExecuted: 'LastNode',
metadata: { key: 'value' },
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: null,
},
validateSignature: true,
pushRef: 'test-ref',
};
const result = migrateRunExecutionData(v0Data);
expect(result).toEqual({
...v0Data,
version: 1,
startData: {
...v0Data.startData,
destinationNode: {
nodeName: 'TestNode',
mode: 'inclusive',
},
originalDestinationNode: {
nodeName: 'OriginalTestNode',
mode: 'inclusive',
},
},
});
});
it('should return V1 data unchanged (no-op)', () => {
const v1Data: IRunExecutionDataV1 = {
version: 1,
startData: {
startNodes: [],
destinationNode: {
nodeName: 'TestNode',
mode: 'exclusive',
},
originalDestinationNode: {
nodeName: 'OriginalTestNode',
mode: 'inclusive',
},
runNodeFilter: ['filter1'],
},
resultData: {
runData: {},
lastNodeExecuted: 'LastNode',
metadata: { key: 'value' },
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: null,
},
validateSignature: true,
pushRef: 'test-ref',
};
const result = migrateRunExecutionData(v1Data);
expect(result).toEqual(v1Data);
});
});
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
import type { INode } from '../src/interfaces';
import { nodeNameToToolName } from '../src/tool-helpers';
describe('nodeNameToToolName', () => {
const getNodeWithName = (name: string): INode => ({
id: 'test-node',
name,
type: 'test',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
});
it('should replace spaces with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test Node'))).toBe('Test_Node');
});
it('should replace dots with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test.Node'))).toBe('Test_Node');
});
it('should replace question marks with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test?Node'))).toBe('Test_Node');
});
it('should replace exclamation marks with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test!Node'))).toBe('Test_Node');
});
it('should replace equals signs with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test=Node'))).toBe('Test_Node');
});
it('should replace multiple special characters with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test.Node?With!Special=Chars'))).toBe(
'Test_Node_With_Special_Chars',
);
});
it('should handle names that already have underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test_Node'))).toBe('Test_Node');
});
it('should handle names with consecutive special characters', () => {
expect(nodeNameToToolName(getNodeWithName('Test..!!??==Node'))).toBe('Test_Node');
});
it('should replace various special characters with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test#+*()[]{}:;,<>/\\\'"%$Node'))).toBe('Test_Node');
});
it('should replace emojis with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('Test 😀 Node'))).toBe('Test_Node');
});
it('should replace multiple emojis with underscores', () => {
expect(nodeNameToToolName(getNodeWithName('🚀 Test 📊 Node 🎉'))).toBe('_Test_Node_');
});
it('should handle complex emoji sequences', () => {
expect(nodeNameToToolName(getNodeWithName('Test 👨‍💻 Node 🔥'))).toBe('Test_Node_');
});
describe('truncation to 64 characters', () => {
it('should not truncate names that are exactly 64 characters', () => {
const name = 'a'.repeat(64);
expect(nodeNameToToolName(name)).toBe(name);
expect(nodeNameToToolName(name)).toHaveLength(64);
});
it('should truncate names longer than 64 characters', () => {
const name = 'a'.repeat(100);
expect(nodeNameToToolName(name)).toBe('a'.repeat(64));
expect(nodeNameToToolName(name)).toHaveLength(64);
});
it('should truncate the firecrawl-like name from the bug report', () => {
const name = 'Scrape a URL and get content as markdown or other formats in Firecrawl';
const result = nodeNameToToolName(name);
expect(result.length).toBeLessThanOrEqual(64);
});
it('should remove trailing underscores after truncation', () => {
// 63 chars of 'a' + space (becomes underscore at position 64) + more text
const name = 'a'.repeat(63) + ' more text';
const result = nodeNameToToolName(name);
expect(result).toBe('a'.repeat(63));
expect(result.length).toBeLessThanOrEqual(64);
});
it('should remove trailing hyphens after truncation', () => {
const name = 'a'.repeat(63) + '-more text';
const result = nodeNameToToolName(name);
expect(result.length).toBeLessThanOrEqual(64);
expect(result).not.toMatch(/[-_]$/);
});
});
describe('when passed a string directly', () => {
it('should replace spaces with underscores', () => {
expect(nodeNameToToolName('Test Node')).toBe('Test_Node');
});
it('should replace dots with underscores', () => {
expect(nodeNameToToolName('Test.Node')).toBe('Test_Node');
});
it('should replace multiple special characters with underscores', () => {
expect(nodeNameToToolName('Test.Node?With!Special=Chars')).toBe(
'Test_Node_With_Special_Chars',
);
});
it('should handle consecutive special characters', () => {
expect(nodeNameToToolName('Test..!!??==Node')).toBe('Test_Node');
});
it('should replace various special characters with underscores', () => {
expect(nodeNameToToolName('Test#+*()[]{}:;,<>/\\\'"%$Node')).toBe('Test_Node');
});
});
});
@@ -0,0 +1,512 @@
import { DateTime, Settings } from 'luxon';
import {
getValueDescription,
tryToParseDateTime,
tryToParseJsonToFormFields,
tryToParseUrl,
validateFieldType,
} from '../src/type-validation';
describe('Type Validation', () => {
describe('string-alphanumeric', () => {
test('should validate and parse alphanumeric strings, not starting with a number', () => {
const VALID_STRINGS = ['abc123', 'ABC123', 'abc_123', '_abc123', 'abcABC123_'];
VALID_STRINGS.forEach((value) =>
expect(validateFieldType('string', value, 'string-alphanumeric')).toEqual({
valid: true,
newValue: value,
}),
);
});
test('should not validate non-alphanumeric strings, or starting with a number', () => {
const INVALID_STRINGS = [
'abc-123',
'abc 123',
'abc@123',
'abc#123',
'abc.123',
'abc$123',
'abc&123',
'abc!123',
'abc(123)',
'bπc123',
'πι',
'123abc', // Cannot start with number
'456_abc', // Cannot start with number
];
INVALID_STRINGS.forEach((value) =>
expect(validateFieldType('string', value, 'string-alphanumeric').valid).toBe(false),
);
});
});
describe('Dates', () => {
test('should validate and cast ISO dates', () => {
const VALID_ISO_DATES = [
'1994-11-05T08:15:30-05:00',
'1994-11-05T13:15:30Z',
'1997-07-16T19:20+01:00',
'1997-07-16T19:20:30+01:00',
'1997-07-16T19:20:30.45+01:00',
'2018-05-16',
'1972-06-30T23:59:40Z',
'2019-03-26T14:00:00.9Z',
'2019-03-26T14:00:00.4999Z',
'2023-05-17T10:52:32+0000',
'2023-05-17T10:52:32+0000',
];
VALID_ISO_DATES.forEach((date) =>
expect(validateFieldType('date', date, 'dateTime')).toEqual({
valid: true,
newValue: expect.any(DateTime),
}),
);
});
test('should validate and cast RFC2822 dates', () => {
const VALID_RFC_DATES = [
'Tue, 04 Jun 2013 07:40:03 -0400',
'Tue, 4 Jun 2013 02:24:39 +0530',
'Wed, 17 May 2023 10:52:32 +0000',
];
VALID_RFC_DATES.forEach((date) =>
expect(validateFieldType('date', date, 'dateTime')).toEqual({
valid: true,
newValue: expect.any(DateTime),
}),
);
});
test('should validate and cast HTTP dates', () => {
const VALID_HTTP_DATES = [
'Wed, 21 Oct 2015 07:28:00 GMT',
'Wed, 01 Jun 2022 08:00:00 GMT',
'Tue, 15 Nov 1994 12:45:26 GMT',
'Wed, 1 Jun 2022 08:00:00 GMT',
];
VALID_HTTP_DATES.forEach((date) =>
expect(validateFieldType('date', date, 'dateTime')).toEqual({
valid: true,
newValue: expect.any(DateTime),
}),
);
});
test('should validate and cast SQL dates', () => {
const VALID_SQL_DATES = ['2008-11-11', '2008-11-11 13:23:44'];
VALID_SQL_DATES.forEach((date) =>
expect(validateFieldType('date', date, 'dateTime')).toEqual({
valid: true,
newValue: expect.any(DateTime),
}),
);
});
test('should validate and cast other valid dates', () => {
const OTHER_VALID_DATES = [
'Wed, 17 May 2023 10:52:32',
'SMT, 17 May 2023 10:52:32',
'1-Feb-2024',
new Date(),
DateTime.now(),
];
OTHER_VALID_DATES.forEach((date) =>
expect(validateFieldType('date', date, 'dateTime')).toEqual({
valid: true,
newValue: expect.any(DateTime),
}),
);
});
test('should not validate invalid dates', () => {
const INVALID_DATES = [
'1994-11-05M08:15:30-05:00',
'18-05-2020',
'',
'1685084980', // We are not supporting timestamps
'1685085012135',
1685084980,
1685085012135,
true,
[],
];
INVALID_DATES.forEach((date) =>
expect(validateFieldType('date', date, 'dateTime').valid).toBe(false),
);
});
});
it('should validate boolean values properly', () => {
const TRUE_VALUES = ['true', 'TRUE', 1, '1', '01'];
TRUE_VALUES.forEach((value) =>
expect(validateFieldType('boolean', value, 'boolean')).toEqual({
valid: true,
newValue: true,
}),
);
const FALSE_VALUES = ['false', 'FALSE', 0, '0', '000', '0000'];
FALSE_VALUES.forEach((value) =>
expect(validateFieldType('boolean', value, 'boolean')).toEqual({
valid: true,
newValue: false,
}),
);
});
it('should not validate invalid boolean values', () => {
const INVALID_VALUES = ['tru', 'fals', 1111, 2, -1, 'yes', 'no'];
INVALID_VALUES.forEach((value) =>
expect(validateFieldType('boolean', value, 'boolean').valid).toEqual(false),
);
});
it('should validate and cast numbers', () => {
const VALID_NUMBERS = [
['1', 1],
['-1', -1],
['1.1', 1.1],
['-1.1', -1.1],
[1, 1],
[true, 1],
];
VALID_NUMBERS.forEach(([value, expected]) =>
expect(validateFieldType('number', value, 'number')).toEqual({
valid: true,
newValue: expected,
}),
);
const INVALID_NUMBERS = ['A', '1,1', '1972-06-30T23:59:40Z', [1, 2]];
INVALID_NUMBERS.forEach((value) =>
expect(validateFieldType('number', value, 'number').valid).toEqual(false),
);
});
it('should validate and cast JSON & JS objects properly', () => {
const VALID_OBJECTS = [
['{"a": 1}', { a: 1 }],
['{a: 1}', { a: 1 }],
["{'a': '1'}", { a: '1' }],
["{'\\'single quoted\\' \"double quoted\"': 1}", { '\'single quoted\' "double quoted"': 1 }],
['{"a": 1, "b": { "c": 10, "d": "test"}}', { a: 1, b: { c: 10, d: 'test' } }],
["{\"a\": 1, b: { 'c': 10, d: 'test'}}", { a: 1, b: { c: 10, d: 'test' } }],
[{ name: 'John' }, { name: 'John' }],
[
{ name: 'John', address: { street: 'Via Roma', city: 'Milano' } },
{ name: 'John', address: { street: 'Via Roma', city: 'Milano' } },
],
];
VALID_OBJECTS.forEach(([value, expected]) =>
expect(validateFieldType('json', value, 'object')).toEqual({
valid: true,
newValue: expected,
}),
);
const INVALID_OBJECTS = [
['one', 'two'],
'1',
'[1]',
'1.1',
1.1,
'"a"',
'["apples", "oranges"]',
[{ name: 'john' }, { name: 'bob' }],
'[ { name: "john" }, { name: "bob" } ]',
];
INVALID_OBJECTS.forEach((value) =>
expect(validateFieldType('json', value, 'object').valid).toEqual(false),
);
});
it('should validate and cast arrays properly', () => {
const VALID_ARRAYS = [
['["apples", "oranges"]', ['apples', 'oranges']],
['[1]', [1]],
['[1, 2]', [1, 2]],
];
VALID_ARRAYS.forEach(([value, expected]) =>
expect(validateFieldType('array', value, 'array')).toEqual({
valid: true,
newValue: expected,
}),
);
const INVALID_ARRAYS = [
'"apples", "oranges"',
'1',
'1.1',
'1, 2',
'1. 2. 3',
'[1, 2, 3',
'1, 2, 3]',
'{1, 2, {3, 4}, 5}',
'1, 2, {3, 4}, 5',
{ name: 'John' },
];
INVALID_ARRAYS.forEach((value) =>
expect(validateFieldType('array', value, 'array').valid).toEqual(false),
);
});
it('should validate options properly', () => {
expect(
validateFieldType('options', 'oranges', 'options', {
valueOptions: [
{ name: 'apples', value: 'apples' },
{ name: 'oranges', value: 'oranges' },
],
}).valid,
).toEqual(true);
expect(
validateFieldType('options', 'something else', 'options', {
valueOptions: [
{ name: 'apples', value: 'apples' },
{ name: 'oranges', value: 'oranges' },
],
}).valid,
).toEqual(false);
});
it('should validate and cast time properly', () => {
const VALID_TIMES = [
['23:23', '23:23'],
['23:23:23', '23:23:23'],
['23:23:23+1000', '23:23:23+1000'],
['23:23:23-1000', '23:23:23-1000'],
['22:00:00+01:00', '22:00:00+01:00'],
['22:00:00-01:00', '22:00:00-01:00'],
['22:00:00+01', '22:00:00+01'],
['22:00:00-01', '22:00:00-01'],
];
VALID_TIMES.forEach(([value, expected]) =>
expect(validateFieldType('time', value, 'time')).toEqual({
valid: true,
newValue: expected,
}),
);
const INVALID_TIMES = ['23:23:23:23', '23', 'foo', '23:23:', '23::23::23'];
INVALID_TIMES.forEach((value) =>
expect(validateFieldType('time', value, 'time').valid).toEqual(false),
);
});
describe('options', () => {
describe('strict=true', () => {
it('should not convert/cast types', () => {
const options = { strict: true };
expect(validateFieldType('test', '42', 'number', options).valid).toBe(false);
expect(validateFieldType('test', 'true', 'boolean', options).valid).toBe(false);
expect(validateFieldType('test', [], 'object', options).valid).toBe(false);
});
});
describe('parseStrings=true', () => {
it('should parse strings from other types', () => {
const options = { parseStrings: true };
expect(validateFieldType('test', 42, 'string')).toEqual({ valid: true, newValue: 42 });
expect(validateFieldType('test', 42, 'string', options)).toEqual({
valid: true,
newValue: '42',
});
expect(validateFieldType('test', true, 'string', options)).toEqual({
valid: true,
newValue: 'true',
});
});
});
});
describe('getValueDescription util function', () => {
it('should return correct description', () => {
expect(getValueDescription('foo')).toBe("'foo'");
expect(getValueDescription(42)).toBe("'42'");
expect(getValueDescription(true)).toBe("'true'");
expect(getValueDescription(null)).toBe("'null'");
expect(getValueDescription(undefined)).toBe("'undefined'");
expect(getValueDescription([{}])).toBe('array');
expect(getValueDescription({})).toBe('object');
});
});
describe('tryToParseDateTime', () => {
it('should NOT use defaultZone if set', () => {
const result = tryToParseDateTime('2025-04-17T06:22:20-04:00', 'Europe/Brussels');
expect(result.zoneName).toEqual('UTC-4');
expect(result.toISO()).toEqual('2025-04-17T06:22:20.000-04:00');
});
it('should use defaultZone if timezone is not set', () => {
const result = tryToParseDateTime('2025-04-17T06:22:20', 'Europe/Brussels');
expect(result.zoneName).toEqual('Europe/Brussels');
expect(result.toISO()).toEqual('2025-04-17T06:22:20.000+02:00');
});
it('should use the system timezone when defaultZone arg is not given', () => {
Settings.defaultZone = 'UTC-7';
const result = tryToParseDateTime('2025-04-17T06:22:20');
expect(result.zoneName).toEqual('UTC-7');
expect(result.toISO()).toEqual('2025-04-17T06:22:20.000-07:00');
});
it('should not impact DateTime zone', () => {
const dateTime = DateTime.fromObject(
{ year: 2025, month: 1, day: 1 },
{ zone: 'Asia/Tokyo' },
);
const result = tryToParseDateTime(dateTime, 'Europe/Brussels');
expect(result.zoneName).toEqual('Asia/Tokyo');
expect(result.toISO()).toEqual('2025-01-01T00:00:00.000+09:00');
});
});
describe('tryToParseJsonToFormFields', () => {
it('should parse html field', () => {
const json = '[{"fieldType": "html", "html": "<div>test</div>"}]';
const fields = tryToParseJsonToFormFields(json);
expect(fields).toEqual([{ fieldType: 'html', html: '<div>test</div>' }]);
});
});
describe('binary', () => {
it('should validate valid binary data objects with data property', () => {
const validBinaryWithData = {
mimeType: 'image/png',
data: 'base64encodeddata',
fileName: 'test.png',
};
expect(validateFieldType('binary', validBinaryWithData, 'binary')).toEqual({
valid: true,
newValue: validBinaryWithData,
});
});
it('should validate valid binary data objects with id property', () => {
const validBinaryWithId = {
mimeType: 'application/pdf',
id: 'binary-id-123',
fileName: 'document.pdf',
};
expect(validateFieldType('binary', validBinaryWithId, 'binary')).toEqual({
valid: true,
newValue: validBinaryWithId,
});
});
it('should validate binary data objects with both data and id', () => {
const validBinaryBoth = {
mimeType: 'text/plain',
data: 'some text content',
id: 'text-id-456',
};
expect(validateFieldType('binary', validBinaryBoth, 'binary')).toEqual({
valid: true,
newValue: validBinaryBoth,
});
});
it('should return valid for null or undefined', () => {
// null and undefined are handled by the early return in validateFieldType
expect(validateFieldType('binary', null, 'binary').valid).toBe(true);
expect(validateFieldType('binary', undefined, 'binary').valid).toBe(true);
});
it('should not validate arrays', () => {
const arrayValue = [{ mimeType: 'image/png', data: 'test' }];
expect(validateFieldType('binary', arrayValue, 'binary').valid).toBe(false);
});
it('should not validate non-object values', () => {
expect(validateFieldType('binary', 'string value', 'binary').valid).toBe(false);
expect(validateFieldType('binary', 123, 'binary').valid).toBe(false);
expect(validateFieldType('binary', true, 'binary').valid).toBe(false);
});
it('should not validate objects without mimeType', () => {
const invalidBinary = {
data: 'base64encodeddata',
fileName: 'test.png',
};
expect(validateFieldType('binary', invalidBinary, 'binary').valid).toBe(false);
});
it('should not validate objects without data or id', () => {
const invalidBinary = {
mimeType: 'image/png',
fileName: 'test.png',
};
expect(validateFieldType('binary', invalidBinary, 'binary').valid).toBe(false);
});
it('should not validate empty objects', () => {
expect(validateFieldType('binary', {}, 'binary').valid).toBe(false);
});
it('should provide proper error message for invalid binary data', () => {
const result = validateFieldType('binary', 'not a binary object', 'binary');
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errorMessage).toContain('Make sure the value is a valid binary data object');
expect(result.errorMessage).toContain("'mimeType' and 'data' or 'id' property");
}
});
});
describe('tryToParseUrl', () => {
it('should accept valid http URLs', () => {
expect(tryToParseUrl('http://example.com')).toBe('http://example.com');
expect(tryToParseUrl('http://example.com/path')).toBe('http://example.com/path');
expect(tryToParseUrl('http://example.com:8080')).toBe('http://example.com:8080');
});
it('should accept valid https URLs', () => {
expect(tryToParseUrl('https://example.com')).toBe('https://example.com');
expect(tryToParseUrl('https://example.com/path?query=1')).toBe(
'https://example.com/path?query=1',
);
});
it('should accept ftp URLs', () => {
expect(tryToParseUrl('ftp://ftp.example.com/file.txt')).toBe(
'ftp://ftp.example.com/file.txt',
);
});
it('should accept file URLs', () => {
expect(tryToParseUrl('file:///path/to/file.txt')).toBe('file:///path/to/file.txt');
});
it('should add https:// prefix when protocol is missing', () => {
expect(tryToParseUrl('example.com')).toBe('https://example.com');
expect(tryToParseUrl('example.com/path')).toBe('https://example.com/path');
});
it('should allow URLs with username and password', () => {
expect(tryToParseUrl('http://user:pass@example.com')).toBe('http://user:pass@example.com');
expect(tryToParseUrl('ftp://user:pass@ftp.example.com')).toBe(
'ftp://user:pass@ftp.example.com',
);
});
it('should reject javascript: protocol URLs', () => {
expect(() => tryToParseUrl('javascript:alert(1)')).toThrow('is not a valid url');
});
it('should reject data: protocol URLs', () => {
expect(() => tryToParseUrl('data:text/html,<script>alert(1)</script>')).toThrow(
'is not a valid url',
);
});
it('should reject invalid URLs', () => {
expect(() => tryToParseUrl('not a url at all')).toThrow('is not a valid url');
expect(() => tryToParseUrl('')).toThrow('is not a valid url');
});
});
});
+965
View File
@@ -0,0 +1,965 @@
import { ALPHABET } from '../src/constants';
import { ApplicationError } from '@n8n/errors';
import { ManualExecutionCancelledError } from '../src/errors/execution-cancelled.error';
import {
jsonParse,
jsonStringify,
deepCopy,
isDomainAllowed,
isObjectEmpty,
fileTypeFromMimeType,
randomInt,
randomString,
hasKey,
isSafeObjectProperty,
setSafeObjectProperty,
sleepWithAbort,
isCommunityPackageName,
sanitizeFilename,
} from '../src/utils';
describe('isObjectEmpty', () => {
it('should handle null and undefined', () => {
expect(isObjectEmpty(null)).toEqual(true);
expect(isObjectEmpty(undefined)).toEqual(true);
});
it('should handle arrays', () => {
expect(isObjectEmpty([])).toEqual(true);
expect(isObjectEmpty([1, 2, 3])).toEqual(false);
});
it('should handle Set and Map', () => {
expect(isObjectEmpty(new Set())).toEqual(true);
expect(isObjectEmpty(new Set([1, 2, 3]))).toEqual(false);
expect(isObjectEmpty(new Map())).toEqual(true);
expect(
isObjectEmpty(
new Map([
['a', 1],
['b', 2],
]),
),
).toEqual(false);
});
it('should handle Buffer, ArrayBuffer, and Uint8Array', () => {
expect(isObjectEmpty(Buffer.from(''))).toEqual(true);
expect(isObjectEmpty(Buffer.from('abcd'))).toEqual(false);
expect(isObjectEmpty(Uint8Array.from([]))).toEqual(true);
expect(isObjectEmpty(Uint8Array.from([1, 2, 3]))).toEqual(false);
expect(isObjectEmpty(new ArrayBuffer(0))).toEqual(true);
expect(isObjectEmpty(new ArrayBuffer(1))).toEqual(false);
});
it('should handle plain objects', () => {
expect(isObjectEmpty({})).toEqual(true);
expect(isObjectEmpty({ a: 1, b: 2 })).toEqual(false);
});
it('should handle instantiated classes', () => {
expect(isObjectEmpty(new (class Test {})())).toEqual(true);
expect(
isObjectEmpty(
new (class Test {
prop = 123;
})(),
),
).toEqual(false);
});
it('should not call Object.keys unless a plain object', () => {
const keySpy = vi.spyOn(Object, 'keys');
const { calls } = keySpy.mock;
const assertCalls = (count: number) => {
if (calls.length !== count) {
throw new ApplicationError('`Object.keys()` was called an unexpected number of times', {
extra: { times: calls.length },
});
}
};
assertCalls(0);
isObjectEmpty(null);
assertCalls(0);
isObjectEmpty([1, 2, 3]);
assertCalls(0);
isObjectEmpty(Buffer.from('123'));
assertCalls(0);
isObjectEmpty({});
assertCalls(1);
});
});
describe('jsonParse', () => {
it('parses JSON', () => {
expect(jsonParse('[1, 2, 3]')).toEqual([1, 2, 3]);
expect(jsonParse('{ "a": 1 }')).toEqual({ a: 1 });
});
it('optionally throws `errorMessage', () => {
expect(() => {
jsonParse('', { errorMessage: 'Invalid JSON' });
}).toThrow('Invalid JSON');
});
it('optionally returns a `fallbackValue`', () => {
expect(jsonParse('', { fallbackValue: { foo: 'bar' } })).toEqual({ foo: 'bar' });
});
describe('acceptJSObject', () => {
const options: Parameters<typeof jsonParse>[1] = {
acceptJSObject: true,
};
it('should handle string values', () => {
const result = jsonParse('{name: \'John\', surname: "Doe"}', options);
expect(result).toEqual({ name: 'John', surname: 'Doe' });
});
it('should handle positive numbers', () => {
const result = jsonParse(
'{int: 12345, float1: 444.111, float2: .123, float3: +.12, oct: 0x10}',
options,
);
expect(result).toEqual({
int: 12345,
float1: 444.111,
float2: 0.123,
float3: 0.12,
oct: 16,
});
});
it('should handle negative numbers', () => {
const result = jsonParse(
'{int: -12345, float1: -444.111, float2: -.123, float3: -.12, oct: -0x10}',
options,
);
expect(result).toEqual({
int: -12345,
float1: -444.111,
float2: -0.123,
float3: -0.12,
oct: -16,
});
});
it('should handle mixed values', () => {
const result = jsonParse('{int: -12345, float: 12.35, text: "hello world"}', options);
expect(result).toEqual({
int: -12345,
float: 12.35,
text: 'hello world',
});
});
});
describe('JSON repair', () => {
describe('Recovery edge cases', () => {
it('should handle simple object with single quotes', () => {
const result = jsonParse("{name: 'John', age: 30}", { repairJSON: true });
expect(result).toEqual({ name: 'John', age: 30 });
});
it('should handle nested objects with single quotes', () => {
const result = jsonParse("{user: {name: 'John', active: true},}", { repairJSON: true });
expect(result).toEqual({ user: { name: 'John', active: true } });
});
it('should handle empty string values', () => {
const result = jsonParse("{key: ''}", { repairJSON: true });
expect(result).toEqual({ key: '' });
});
it('should handle numeric string values', () => {
const result = jsonParse("{key: '123'}", { repairJSON: true });
expect(result).toEqual({ key: '123' });
});
it('should handle multiple keys with trailing comma', () => {
const result = jsonParse("{a: '1', b: '2', c: '3',}", { repairJSON: true });
expect(result).toEqual({ a: '1', b: '2', c: '3' });
});
it('should recover single quotes around strings', () => {
const result = jsonParse("{key: 'value'}", { repairJSON: true });
expect(result).toEqual({ key: 'value' });
});
it('should recover unquoted keys', () => {
const result = jsonParse("{myKey: 'value'}", { repairJSON: true });
expect(result).toEqual({ myKey: 'value' });
});
it('should recover trailing commas in objects', () => {
const result = jsonParse("{key: 'value',}", { repairJSON: true });
expect(result).toEqual({ key: 'value' });
});
it('should recover trailing commas in nested objects', () => {
const result = jsonParse("{outer: {inner: 'value',},}", { repairJSON: true });
expect(result).toEqual({ outer: { inner: 'value' } });
});
it('should recover multiple issues at once', () => {
const result = jsonParse("{key1: 'value1', key2: 'value2',}", { repairJSON: true });
expect(result).toEqual({ key1: 'value1', key2: 'value2' });
});
it('should recover numeric values with single quotes', () => {
const result = jsonParse("{key: '123'}", { repairJSON: true });
expect(result).toEqual({ key: '123' });
});
it('should recover boolean values with single quotes', () => {
const result = jsonParse("{key: 'true'}", { repairJSON: true });
expect(result).toEqual({ key: 'true' });
});
it('should handle urls', () => {
const result = jsonParse('{"key": "https://example.com",}', { repairJSON: true });
expect(result).toEqual({ key: 'https://example.com' });
});
it('should handle ipv6 addresses', () => {
const result = jsonParse('{"key": "2a01:c50e:3544:bd00:4df0:7609:251a:f6d0",}', {
repairJSON: true,
});
expect(result).toEqual({ key: '2a01:c50e:3544:bd00:4df0:7609:251a:f6d0' });
});
it('should handle single quotes containing double quotes', () => {
const result = jsonParse('{key: \'value with "quotes" inside\'}', { repairJSON: true });
expect(result).toEqual({ key: 'value with "quotes" inside' });
});
it('should handle escaped single quotes', () => {
const result = jsonParse("{key: 'it\\'s escaped'}", { repairJSON: true });
expect(result).toEqual({ key: "it's escaped" });
});
it('should handle keys containing hyphens', () => {
const result = jsonParse("{key-with-dash: 'value'}", { repairJSON: true });
expect(result).toEqual({ 'key-with-dash': 'value' });
});
it('should handle keys containing dots', () => {
const result = jsonParse("{key.name: 'value'}", { repairJSON: true });
expect(result).toEqual({ 'key.name': 'value' });
});
it('should handle unquoted string values', () => {
const result = jsonParse('{key: value}', { repairJSON: true });
expect(result).toEqual({ key: 'value' });
});
it('should handle unquoted multi-word values', () => {
const result = jsonParse('{key: some text}', { repairJSON: true });
expect(result).toEqual({ key: 'some text' });
});
it('should handle input with double quotes mixed with single quotes', () => {
const result = jsonParse('{key: "value with \'single\' quotes"}', { repairJSON: true });
expect(result).toEqual({ key: "value with 'single' quotes" });
});
it('should handle keys starting with numbers', () => {
const result = jsonParse("{123key: 'value'}", { repairJSON: true });
expect(result).toEqual({ '123key': 'value' });
});
it('should handle nested objects containing quotes', () => {
const result = jsonParse("{outer: {inner: 'value with \"quotes\"', other: 'test'},}", {
repairJSON: true,
});
expect(result).toEqual({ outer: { inner: 'value with "quotes"', other: 'test' } });
});
it('should handle complex nested quote conflicts', () => {
const result = jsonParse("{key: 'value with \"quotes\" inside', nested: {inner: 'test'}}", {
repairJSON: true,
});
expect(result).toEqual({ key: 'value with "quotes" inside', nested: { inner: 'test' } });
});
});
});
});
describe('jsonStringify', () => {
const source: any = { a: 1, b: 2, d: new Date(1680089084200), r: new RegExp('^test$', 'ig') };
source.c = source;
it('should throw errors on circular references by default', () => {
expect(() => jsonStringify(source)).toThrow('Converting circular structure to JSON');
});
it('should break circular references when requested', () => {
expect(jsonStringify(source, { replaceCircularRefs: true })).toEqual(
'{"a":1,"b":2,"d":"2023-03-29T11:24:44.200Z","r":{},"c":"[Circular Reference]"}',
);
});
it('should not detect duplicates as circular references', () => {
const y = { z: 5 };
const x = [y, y, { y }];
expect(jsonStringify(x, { replaceCircularRefs: true })).toEqual(
'[{"z":5},{"z":5},{"y":{"z":5}}]',
);
});
});
describe('deepCopy', () => {
it('should deep copy an object', () => {
const serializable = {
x: 1,
y: 2,
toJSON: () => 'x:1,y:2',
};
const object = {
deep: {
props: {
list: [{ a: 1 }, { b: 2 }, { c: 3 }],
},
arr: [1, 2, 3],
},
serializable,
arr: [
{
prop: {
list: ['a', 'b', 'c'],
},
},
],
func: () => {},
date: new Date(1667389172201),
undef: undefined,
nil: null,
bool: true,
num: 1,
};
const copy = deepCopy(object);
expect(copy).not.toBe(object);
expect(copy.arr).toEqual(object.arr);
expect(copy.arr).not.toBe(object.arr);
expect(copy.date).toBe('2022-11-02T11:39:32.201Z');
expect(copy.serializable).toBe(serializable.toJSON());
expect(copy.deep.props).toEqual(object.deep.props);
expect(copy.deep.props).not.toBe(object.deep.props);
});
it('should avoid max call stack in case of circular deps', () => {
const object: Record<string, any> = {
deep: {
props: {
list: [{ a: 1 }, { b: 2 }, { c: 3 }],
},
arr: [1, 2, 3],
},
arr: [
{
prop: {
list: ['a', 'b', 'c'],
},
},
],
func: () => {},
date: new Date(1667389172201),
undef: undefined,
nil: null,
bool: true,
num: 1,
};
object.circular = object;
object.deep.props.circular = object;
object.deep.arr.push(object);
const copy = deepCopy(object);
expect(copy).not.toBe(object);
expect(copy.arr).toEqual(object.arr);
expect(copy.arr).not.toBe(object.arr);
expect(copy.date).toBe('2022-11-02T11:39:32.201Z');
expect(copy.deep.props.circular).toBe(copy);
expect(copy.deep.props.circular).not.toBe(object);
expect(copy.deep.arr.slice(-1)[0]).toBe(copy);
expect(copy.deep.arr.slice(-1)[0]).not.toBe(object);
});
});
describe('fileTypeFromMimeType', () => {
it('should recognize json', () => {
expect(fileTypeFromMimeType('application/json')).toEqual('json');
});
it('should recognize html', () => {
expect(fileTypeFromMimeType('text/html')).toEqual('html');
});
it('should recognize image', () => {
expect(fileTypeFromMimeType('image/jpeg')).toEqual('image');
expect(fileTypeFromMimeType('image/png')).toEqual('image');
expect(fileTypeFromMimeType('image/avif')).toEqual('image');
expect(fileTypeFromMimeType('image/webp')).toEqual('image');
});
it('should recognize audio', () => {
expect(fileTypeFromMimeType('audio/wav')).toEqual('audio');
expect(fileTypeFromMimeType('audio/webm')).toEqual('audio');
expect(fileTypeFromMimeType('audio/ogg')).toEqual('audio');
expect(fileTypeFromMimeType('audio/mp3')).toEqual('audio');
});
it('should recognize video', () => {
expect(fileTypeFromMimeType('video/mp4')).toEqual('video');
expect(fileTypeFromMimeType('video/webm')).toEqual('video');
expect(fileTypeFromMimeType('video/ogg')).toEqual('video');
});
it('should recognize text', () => {
expect(fileTypeFromMimeType('text/plain')).toEqual('text');
expect(fileTypeFromMimeType('text/css')).toEqual('text');
expect(fileTypeFromMimeType('text/html')).not.toEqual('text');
expect(fileTypeFromMimeType('text/javascript')).toEqual('text');
expect(fileTypeFromMimeType('application/javascript')).toEqual('text');
});
it('should recognize pdf', () => {
expect(fileTypeFromMimeType('application/pdf')).toEqual('pdf');
});
});
const repeat = (fn: () => void, times = 10) => Array(times).fill(0).forEach(fn);
describe('randomInt', () => {
it('should generate random integers', () => {
repeat(() => {
const result = randomInt(10);
expect(result).toBeLessThanOrEqual(10);
expect(result).toBeGreaterThanOrEqual(0);
});
});
it('should generate random in range', () => {
repeat(() => {
const result = randomInt(10, 100);
expect(result).toBeLessThanOrEqual(100);
expect(result).toBeGreaterThanOrEqual(10);
});
});
});
describe('randomString', () => {
it('should return a random string of the specified length', () => {
repeat(() => {
const result = randomString(42);
expect(result).toHaveLength(42);
});
});
it('should return a random string of the in the length range', () => {
repeat(() => {
const result = randomString(10, 100);
expect(result.length).toBeGreaterThanOrEqual(10);
expect(result.length).toBeLessThanOrEqual(100);
});
});
it('should only contain characters from the specified character set', () => {
repeat(() => {
const result = randomString(1000);
result.split('').every((char) => ALPHABET.includes(char));
});
});
});
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
? true
: false;
describe('hasKey', () => {
it('should return false if the input is null', () => {
const x = null;
const result = hasKey(x, 'key');
expect(result).toEqual(false);
});
it('should return false if the input is undefined', () => {
const x = undefined;
const result = hasKey(x, 'key');
expect(result).toEqual(false);
});
it('should return false if the input is a number', () => {
const x = 1;
const result = hasKey(x, 'key');
expect(result).toEqual(false);
});
it('should return false if the input is an array out of bounds', () => {
const x = [1, 2];
const result = hasKey(x, 5);
expect(result).toEqual(false);
});
it('should return true if the input is an array within bounds', () => {
const x = [1, 2];
const result = hasKey(x, 1);
expect(result).toEqual(true);
});
it('should return true if the input is an array with the key `length`', () => {
const x = [1, 2];
const result = hasKey(x, 'length');
expect(result).toEqual(true);
});
it('should return false if the input is an array with the key `toString`', () => {
const x = [1, 2];
const result = hasKey(x, 'toString');
expect(result).toEqual(false);
});
it('should return false if the input is an object without the key', () => {
const x = { a: 3 };
const result = hasKey(x, 'a');
expect(result).toEqual(true);
});
it('should return true if the input is an object with the key', () => {
const x = { a: 3 };
const result = hasKey(x, 'b');
expect(result).toEqual(false);
});
it('should provide a type guard', () => {
const x: unknown = { a: 3 };
if (hasKey(x, '0')) {
const y: Expect<Equal<typeof x, Record<'0', unknown>>> = true;
y;
} else {
const z: Expect<Equal<typeof x, unknown>> = true;
z;
}
});
});
describe('isSafeObjectProperty', () => {
it.each([
['__proto__', false],
['prototype', false],
['constructor', false],
['getPrototypeOf', false],
['mainModule', false],
['binding', false],
['_load', false],
['safeKey', true],
['anotherKey', true],
['toString', true],
])('should return %s for key "%s"', (key, expected) => {
expect(isSafeObjectProperty(key)).toBe(expected);
});
});
describe('setSafeObjectProperty', () => {
it.each([
['safeKey', 123, { safeKey: 123 }],
['__proto__', 456, {}],
['constructor', 'test', {}],
])('should set property "%s" safely', (key, value, expected) => {
const obj: Record<string, unknown> = {};
setSafeObjectProperty(obj, key, value);
expect(obj).toEqual(expected);
});
});
describe('sleepWithAbort', () => {
it('should resolve after the specified time when not aborted', async () => {
const start = Date.now();
await sleepWithAbort(100);
const end = Date.now();
const elapsed = end - start;
// Allow some tolerance for timing
expect(elapsed).toBeGreaterThanOrEqual(90);
expect(elapsed).toBeLessThan(200);
});
it('should reject immediately if abort signal is already aborted', async () => {
const abortController = new AbortController();
abortController.abort();
await expect(sleepWithAbort(1000, abortController.signal)).rejects.toThrow(
ManualExecutionCancelledError,
);
});
it('should reject when abort signal is triggered during sleep', async () => {
const abortController = new AbortController();
// Start the sleep and abort after 50ms
setTimeout(() => abortController.abort(), 50);
const start = Date.now();
await expect(sleepWithAbort(1000, abortController.signal)).rejects.toThrow(
ManualExecutionCancelledError,
);
const end = Date.now();
const elapsed = end - start;
// Should have been aborted after ~50ms, not the full 1000ms
expect(elapsed).toBeLessThan(200);
});
it('should work without abort signal', async () => {
const start = Date.now();
await sleepWithAbort(100, undefined);
const end = Date.now();
const elapsed = end - start;
expect(elapsed).toBeGreaterThanOrEqual(90);
expect(elapsed).toBeLessThan(200);
});
it('should clean up timeout when aborted during sleep', async () => {
const abortController = new AbortController();
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
// Start the sleep and abort after 50ms
const sleepPromise = sleepWithAbort(1000, abortController.signal);
setTimeout(() => abortController.abort(), 50);
await expect(sleepPromise).rejects.toThrow(ManualExecutionCancelledError);
// clearTimeout should have been called to clean up
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});
describe('isDomainAllowed', () => {
describe('when no allowed domains are specified', () => {
it('should allow all domains when allowedDomains is empty', () => {
expect(isDomainAllowed('https://example.com', { allowedDomains: '' })).toBe(true);
});
it('should allow all domains when allowedDomains contains only whitespace', () => {
expect(isDomainAllowed('https://example.com', { allowedDomains: ' ' })).toBe(true);
});
});
describe('in strict validation mode', () => {
it('should allow exact domain matches', () => {
expect(
isDomainAllowed('https://example.com', {
allowedDomains: 'example.com',
}),
).toBe(true);
});
it('should allow domains from a comma-separated list', () => {
expect(
isDomainAllowed('https://example.com', {
allowedDomains: 'test.com,example.com,other.org',
}),
).toBe(true);
});
it('should handle whitespace in allowed domains list', () => {
expect(
isDomainAllowed('https://example.com', {
allowedDomains: ' test.com , example.com , other.org ',
}),
).toBe(true);
});
it('should block non-matching domains', () => {
expect(
isDomainAllowed('https://malicious.com', {
allowedDomains: 'example.com',
}),
).toBe(false);
});
it('should block subdomains not set', () => {
expect(
isDomainAllowed('https://sub.example.com', {
allowedDomains: 'example.com',
}),
).toBe(false);
});
});
describe('with wildcard domains', () => {
it('should allow matching wildcard domains', () => {
expect(
isDomainAllowed('https://test.example.com', {
allowedDomains: '*.example.com',
}),
).toBe(true);
});
it('should block correctly for wildcards', () => {
expect(
isDomainAllowed('https://domain-test.com', {
allowedDomains: '*.test.com,example.com',
}),
).toBe(false);
});
it('should allow nested subdomains with wildcards', () => {
expect(
isDomainAllowed('https://deep.nested.example.com', {
allowedDomains: '*.example.com',
}),
).toBe(true);
});
it('should block non-matching domains with wildcards', () => {
expect(
isDomainAllowed('https://example.org', {
allowedDomains: '*.example.com',
}),
).toBe(false);
});
it('should block domains that share suffix but are not subdomains', () => {
expect(
isDomainAllowed('https://malicious-example.com', {
allowedDomains: '*.example.com',
}),
).toBe(false);
});
it('should not allow base domain with wildcard alone', () => {
expect(
isDomainAllowed('https://example.com', {
allowedDomains: '*.example.com',
}),
).toBe(false);
});
it('should allow base domain when explicitly specified alongside wildcard', () => {
expect(
isDomainAllowed('https://example.com', {
allowedDomains: 'example.com,*.example.com',
}),
).toBe(true);
expect(
isDomainAllowed('https://sub.example.com', {
allowedDomains: 'example.com,*.example.com',
}),
).toBe(true);
});
it('should handle empty wildcard suffix', () => {
expect(
isDomainAllowed('https://example.com', {
allowedDomains: '*.',
}),
).toBe(false);
});
});
describe('edge cases', () => {
it('should handle invalid URLs safely', () => {
expect(
isDomainAllowed('not-a-valid-url', {
allowedDomains: 'example.com',
}),
).toBe(false);
});
it('should handle URLs with ports', () => {
expect(
isDomainAllowed('https://example.com:8080/path', {
allowedDomains: 'example.com',
}),
).toBe(true);
});
it('should handle URLs with authentication', () => {
expect(
isDomainAllowed('https://user:pass@example.com', {
allowedDomains: 'example.com',
}),
).toBe(true);
});
it('should handle URLs with query parameters and fragments', () => {
expect(
isDomainAllowed('https://example.com/path?query=test#fragment', {
allowedDomains: 'example.com',
}),
).toBe(true);
});
it('should handle IP addresses', () => {
expect(
isDomainAllowed('https://192.168.1.1', {
allowedDomains: '192.168.1.1',
}),
).toBe(true);
});
it('should handle empty URLs', () => {
expect(
isDomainAllowed('', {
allowedDomains: 'example.com',
}),
).toBe(false);
});
it('should be case-insensitive for domains', () => {
expect(
isDomainAllowed('https://EXAMPLE.COM', {
allowedDomains: 'example.com',
}),
).toBe(true);
expect(
isDomainAllowed('https://example.com', {
allowedDomains: 'EXAMPLE.COM',
}),
).toBe(true);
expect(
isDomainAllowed('https://Example.Com', {
allowedDomains: 'example.com',
}),
).toBe(true);
});
it('should handle trailing dots in hostnames', () => {
expect(
isDomainAllowed('https://example.com.', {
allowedDomains: 'example.com',
}),
).toBe(true);
expect(
isDomainAllowed('https://example.com', {
allowedDomains: 'example.com.',
}),
).toBe(true);
});
it('should handle empty hostnames', () => {
expect(
isDomainAllowed('http://', {
allowedDomains: 'example.com',
}),
).toBe(false);
});
});
});
describe('isCommunityPackageName', () => {
// Standard community package names
it('should identify standard community node package names', () => {
expect(isCommunityPackageName('n8n-nodes-example')).toBe(true);
expect(isCommunityPackageName('n8n-nodes-custom')).toBe(true);
expect(isCommunityPackageName('n8n-nodes-test')).toBe(true);
});
// Scoped package names
it('should identify scoped community node package names', () => {
expect(isCommunityPackageName('@username/n8n-nodes-example')).toBe(true);
expect(isCommunityPackageName('@org/n8n-nodes-custom')).toBe(true);
expect(isCommunityPackageName('@test-scope/n8n-nodes-test-name')).toBe(true);
});
it('should identify scoped packages with other characters', () => {
expect(isCommunityPackageName('n8n-nodes-my_package')).toBe(true);
expect(isCommunityPackageName('@user/n8n-nodes-with_underscore')).toBe(true);
expect(isCommunityPackageName('@user_name/n8n-nodes-example')).toBe(true);
expect(isCommunityPackageName('@n8n-io/n8n-nodes-test')).toBe(true);
expect(isCommunityPackageName('@n8n.io/n8n-nodes-test')).toBe(true);
});
it('should handle mixed cases', () => {
expect(isCommunityPackageName('@user-name_org/n8n-nodes-mixed-case_example')).toBe(true);
expect(isCommunityPackageName('@mixed_style-org/n8n-nodes-complex_name-format')).toBe(true);
expect(isCommunityPackageName('@my.mixed_style-org/n8n-nodes-complex_name-format')).toBe(true);
});
// Official n8n packages that should not be identified as community packages
it('should not identify official n8n packages as community nodes', () => {
expect(isCommunityPackageName('@n8n/n8n-nodes-example')).toBe(false);
expect(isCommunityPackageName('n8n-nodes-base')).toBe(false);
});
// Additional edge cases
it('should handle edge cases correctly', () => {
// Non-matching patterns
expect(isCommunityPackageName('not-n8n-nodes')).toBe(false);
expect(isCommunityPackageName('n8n-core')).toBe(false);
// With node name after package
expect(isCommunityPackageName('n8n-nodes-example.NodeName')).toBe(true);
expect(isCommunityPackageName('@user/n8n-nodes-example.NodeName')).toBe(true);
});
// Multiple executions to test regex state
it('should work correctly with multiple consecutive calls', () => {
expect(isCommunityPackageName('@user/n8n-nodes-example')).toBe(true);
expect(isCommunityPackageName('n8n-nodes-base')).toBe(false);
expect(isCommunityPackageName('@test-scope/n8n-nodes-test')).toBe(true);
});
});
describe('sanitizeFilename', () => {
it('should return normal filenames unchanged', () => {
expect(sanitizeFilename('normalfile')).toBe('normalfile');
expect(sanitizeFilename('my-file_v2')).toBe('my-file_v2');
expect(sanitizeFilename('test.txt')).toBe('test.txt');
});
it('should handle empty and invalid inputs', () => {
expect(sanitizeFilename('')).toBe('untitled');
});
it('should handle edge cases', () => {
expect(sanitizeFilename('.')).toBe('untitled');
expect(sanitizeFilename('..')).toBe('untitled');
});
it('should prevent path traversal attacks', () => {
// Basic path traversal attempts - extracts just the filename
expect(sanitizeFilename('../../../etc/passwd')).toBe('passwd');
expect(sanitizeFilename('..\\..\\..\\windows\\system32')).toBe('system32');
// Path traversal with file extension
expect(sanitizeFilename('../file.txt')).toBe('file.txt');
expect(sanitizeFilename('../../secret.json')).toBe('secret.json');
// Nested path separators - extracts just the final component
expect(sanitizeFilename('path/to/file')).toBe('file');
expect(sanitizeFilename('path\\to\\file')).toBe('file');
// Hidden files and nested directories
expect(sanitizeFilename('../../../.ssh/authorized_keys')).toBe('authorized_keys');
expect(sanitizeFilename('../../../etc/cron.d/backdoor')).toBe('backdoor');
});
it('should extract filename from full file paths', () => {
// Unix paths
expect(sanitizeFilename('/tmp/n8n-upload-xyz/original.pdf')).toBe('original.pdf');
expect(sanitizeFilename('/home/user/documents/report.docx')).toBe('report.docx');
// Windows paths
expect(sanitizeFilename('C:\\Users\\Admin\\file.txt')).toBe('file.txt');
expect(sanitizeFilename('D:\\temp\\upload\\image.png')).toBe('image.png');
});
it('should remove null bytes', () => {
expect(sanitizeFilename('file\0name.txt')).toBe('filename.txt');
expect(sanitizeFilename('\0\0\0')).toBe('untitled');
});
});
@@ -0,0 +1,146 @@
import { calculateWorkflowChecksum, type WorkflowSnapshot } from '../src/workflow-checksum';
describe('calculateWorkflowChecksum', () => {
const baseWorkflow: WorkflowSnapshot = {
name: 'Test Workflow',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [250, 300],
parameters: {},
},
],
connections: {},
settings: {
executionOrder: 'v1',
timezone: 'America/New_York',
},
};
it('should generate the same checksum for identical workflows', async () => {
const checksum1 = await calculateWorkflowChecksum(baseWorkflow);
const checksum2 = await calculateWorkflowChecksum(baseWorkflow);
expect(checksum1).toBe(checksum2);
});
it('should generate different checksums when a setting changes', async () => {
const workflow1: WorkflowSnapshot = {
...baseWorkflow,
settings: {
executionOrder: 'v1',
timezone: 'America/New_York',
},
};
const workflow2: WorkflowSnapshot = {
...baseWorkflow,
settings: {
executionOrder: 'v1',
timezone: 'Europe/London',
},
};
const checksum1 = await calculateWorkflowChecksum(workflow1);
const checksum2 = await calculateWorkflowChecksum(workflow2);
expect(checksum1).not.toBe(checksum2);
});
it('should generate different checksums when a setting is added', async () => {
const workflow1: WorkflowSnapshot = {
...baseWorkflow,
settings: {
executionOrder: 'v1',
},
};
const workflow2: WorkflowSnapshot = {
...baseWorkflow,
settings: {
executionOrder: 'v1',
timezone: 'America/New_York',
},
};
const checksum1 = await calculateWorkflowChecksum(workflow1);
const checksum2 = await calculateWorkflowChecksum(workflow2);
expect(checksum1).not.toBe(checksum2);
});
it('should generate different checksums when a setting is removed', async () => {
const workflow1: WorkflowSnapshot = {
...baseWorkflow,
settings: {
executionOrder: 'v1',
timezone: 'America/New_York',
},
};
const workflow2: WorkflowSnapshot = {
...baseWorkflow,
settings: {
executionOrder: 'v1',
},
};
const checksum1 = await calculateWorkflowChecksum(workflow1);
const checksum2 = await calculateWorkflowChecksum(workflow2);
expect(checksum1).not.toBe(checksum2);
});
it('should generate same checksum when a setting is undefined i.e. missing', async () => {
const workflow1: WorkflowSnapshot = {
...baseWorkflow,
settings: {
executionOrder: 'v1',
timezone: undefined,
},
};
const workflow2: WorkflowSnapshot = {
...baseWorkflow,
settings: {
executionOrder: 'v1',
},
};
const checksum1 = await calculateWorkflowChecksum(workflow1);
const checksum2 = await calculateWorkflowChecksum(workflow2);
// undefined fields should be ignored, so checksums should be the same
expect(checksum1).toBe(checksum2);
});
it('should handle complex nested metadata', async () => {
const workflow1: WorkflowSnapshot = {
...baseWorkflow,
meta: {
nested: {
foo: 'bar',
baz: 123,
},
},
};
const workflow2: WorkflowSnapshot = {
...baseWorkflow,
meta: {
nested: {
foo: 'bar',
baz: 456, // Changed
},
},
};
const checksum1 = await calculateWorkflowChecksum(workflow1);
const checksum2 = await calculateWorkflowChecksum(workflow2);
expect(checksum1).not.toBe(checksum2);
});
});
@@ -0,0 +1,112 @@
import { ExpressionError } from '../src/errors/expression.error';
import { createEnvProvider, createEnvProviderState } from '../src/workflow-data-proxy-env-provider';
describe('createEnvProviderState', () => {
afterEach(() => {
delete process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE;
});
it('should return the state with process available and env access allowed', () => {
process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE = 'false';
expect(createEnvProviderState()).toEqual({
isProcessAvailable: true,
isEnvAccessBlocked: false,
env: process.env,
});
});
it('should block env access when N8N_BLOCK_ENV_ACCESS_IN_NODE is set to "true"', () => {
process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE = 'true';
expect(createEnvProviderState()).toEqual({
isProcessAvailable: true,
isEnvAccessBlocked: true,
env: {},
});
});
it('should block env access when N8N_BLOCK_ENV_ACCESS_IN_NODE is not set', () => {
expect(createEnvProviderState()).toEqual({
isProcessAvailable: true,
isEnvAccessBlocked: true,
env: {},
});
});
it('should handle process not being available', () => {
const originalProcess = global.process;
try {
// @ts-expect-error process is read-only
global.process = undefined;
expect(createEnvProviderState()).toEqual({
isProcessAvailable: false,
isEnvAccessBlocked: false,
env: {},
});
} finally {
global.process = originalProcess;
}
});
});
describe('createEnvProvider', () => {
afterEach(() => {
delete process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE;
});
it('should return true when checking for a property using "has"', () => {
const proxy = createEnvProvider(0, 0, createEnvProviderState());
expect('someProperty' in proxy).toBe(true);
});
it('should return the value from process.env if access is allowed', () => {
process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE = 'false';
process.env.TEST_ENV_VAR = 'test_value';
const proxy = createEnvProvider(0, 0, createEnvProviderState());
expect(proxy.TEST_ENV_VAR).toBe('test_value');
});
it('should throw ExpressionError when process is unavailable', () => {
vi.useFakeTimers({ now: new Date() });
const originalProcess = global.process;
try {
// @ts-expect-error process is read-only
global.process = undefined;
const proxy = createEnvProvider(1, 1, createEnvProviderState());
expect(() => proxy.someEnvVar).toThrowError(
new ExpressionError('not accessible via UI, please run node', {
runIndex: 1,
itemIndex: 1,
}),
);
} finally {
global.process = originalProcess;
vi.useRealTimers();
}
});
it('should throw ExpressionError when env access is blocked', () => {
vi.useFakeTimers({ now: new Date() });
try {
process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE = 'true';
const proxy = createEnvProvider(1, 1, createEnvProviderState());
expect(() => proxy.someEnvVar).toThrowError(
new ExpressionError('access to env vars denied', {
causeDetailed:
'If you need access please contact the administrator to remove the environment variable N8N_BLOCK_ENV_ACCESS_IN_NODE',
runIndex: 1,
itemIndex: 1,
}),
);
} finally {
vi.useRealTimers();
}
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
import type { AutoPublishMode } from '../src/workflow-environments-helper';
import { shouldAutoPublishWorkflow } from '../src/workflow-environments-helper';
describe('shouldAutoPublishWorkflow', () => {
describe('with autoPublish: "none"', () => {
test.each([
{
name: 'new workflow',
params: {
isNewWorkflow: true,
isLocalPublished: false,
isRemoteArchived: false,
autoPublish: 'none' as AutoPublishMode,
},
expected: false,
},
{
name: 'existing published workflow',
params: {
isNewWorkflow: false,
isLocalPublished: true,
isRemoteArchived: false,
autoPublish: 'none' as AutoPublishMode,
},
expected: false,
},
{
name: 'existing unpublished workflow',
params: {
isNewWorkflow: false,
isLocalPublished: false,
isRemoteArchived: false,
autoPublish: 'none' as AutoPublishMode,
},
expected: false,
},
])('should return false for $name', ({ params, expected }) => {
expect(shouldAutoPublishWorkflow(params)).toBe(expected);
});
});
describe('with autoPublish: "published"', () => {
test.each([
{
name: 'new workflow',
params: {
isNewWorkflow: true,
isLocalPublished: false,
isRemoteArchived: false,
autoPublish: 'published' as AutoPublishMode,
},
expected: false,
},
{
name: 'existing published workflow',
params: {
isNewWorkflow: false,
isLocalPublished: true,
isRemoteArchived: false,
autoPublish: 'published' as AutoPublishMode,
},
expected: true,
},
{
name: 'existing unpublished workflow',
params: {
isNewWorkflow: false,
isLocalPublished: false,
isRemoteArchived: false,
autoPublish: 'published' as AutoPublishMode,
},
expected: false,
},
])('should return $expected for $name', ({ params, expected }) => {
expect(shouldAutoPublishWorkflow(params)).toBe(expected);
});
});
describe('with autoPublish: "all"', () => {
test.each([
{
name: 'new workflow',
params: {
isNewWorkflow: true,
isLocalPublished: false,
isRemoteArchived: false,
autoPublish: 'all' as AutoPublishMode,
},
expected: true,
},
{
name: 'existing published workflow',
params: {
isNewWorkflow: false,
isLocalPublished: true,
isRemoteArchived: false,
autoPublish: 'all' as AutoPublishMode,
},
expected: true,
},
{
name: 'existing unpublished workflow',
params: {
isNewWorkflow: false,
isLocalPublished: false,
isRemoteArchived: false,
autoPublish: 'all' as AutoPublishMode,
},
expected: true,
},
])('should return $expected for $name', ({ params, expected }) => {
expect(shouldAutoPublishWorkflow(params)).toBe(expected);
});
});
describe('archived workflows', () => {
test('should never activate archived workflows regardless of settings', () => {
const autoPublishModes = [
{ autoPublish: 'none' as AutoPublishMode },
{ autoPublish: 'published' as AutoPublishMode },
{ autoPublish: 'all' as AutoPublishMode },
];
const workflowStates = [
{ isNewWorkflow: true, isLocalPublished: false },
{ isNewWorkflow: false, isLocalPublished: true },
{ isNewWorkflow: false, isLocalPublished: false },
];
autoPublishModes.forEach(({ autoPublish }) => {
workflowStates.forEach(({ isNewWorkflow, isLocalPublished }) => {
expect(
shouldAutoPublishWorkflow({
isNewWorkflow,
isLocalPublished,
isRemoteArchived: true,
autoPublish,
}),
).toBe(false);
});
});
});
});
});
@@ -0,0 +1,41 @@
import * as Helpers from './helpers';
import type { NodeParameterValueType } from '../src';
import { Workflow } from '../src/workflow';
describe('WorkflowExpression', () => {
describe('getParameterValue()', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
id: '1',
nodes: [
{
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
const expression = workflow.expression;
const evaluate = (value: NodeParameterValueType) =>
expression.getParameterValue(value, null, 0, 0, 'node', [], 'manual', {});
it('should resolve $parameter["&key"] sibling reference within an object', () => {
// n8n uses the `&`-prefixed syntax internally (e.g. in node parameter definitions)
// to reference sibling fields: `={{ $parameter["&key"].split("|")[1] }}`
// getParameterValue must pass the parent object as siblingParameters so these resolve.
const result = evaluate({
key: 'title|display',
type: '={{$parameter["&key"].split("|")[1]}}',
});
expect(result).toEqual({ key: 'title|display', type: 'display' });
});
});
});
@@ -0,0 +1,71 @@
import { vi } from 'vitest';
import type { INode, INodes, INodeType, INodeTypeDescription } from '../src/interfaces';
import type { INodeTypesGetter } from '../src/workflow-validation';
import { validateWorkflowHasTriggerLikeNode } from '../src/workflow-validation';
describe('validateWorkflowHasTriggerLikeNode', () => {
const disabledNode = { type: 'triggerNode', disabled: true } as INode;
const unknownNode = { type: 'unknownNode' } as INode;
const noTriggersNode = { type: 'noTriggersNode' } as INode;
const pollNode = { type: 'pollNode' } as INode;
const triggerNode = { type: 'triggerNode' } as INode;
const webhookNode = { type: 'webhookNode' } as INode;
const nodeTypes: INodeTypesGetter = {
getByNameAndVersion: vi.fn(),
};
beforeEach(() => {
vi.mocked(nodeTypes.getByNameAndVersion).mockImplementation((type): INodeType | undefined => {
if (type === 'unknownNode') return undefined;
const nodeType: Partial<INodeType> = {
poll: undefined,
trigger: undefined,
webhook: undefined,
description: {} as INodeTypeDescription,
};
if (type === 'pollNode') nodeType.poll = vi.fn();
if (type === 'triggerNode') nodeType.trigger = vi.fn();
if (type === 'webhookNode') nodeType.webhook = vi.fn();
return nodeType as INodeType;
});
});
test.each([
['should skip disabled nodes', { disabledNode }, [], false],
['should skip nodes marked as ignored', { triggerNode }, ['triggerNode'], false],
['should skip unknown nodes', { unknownNode }, [], false],
['should skip nodes with no trigger method', { noTriggersNode }, [], false],
['should activate if poll method exists', { pollNode }, [], true],
['should activate if trigger method exists', { triggerNode }, [], true],
['should activate if webhook method exists', { webhookNode }, [], true],
[
'should ignore multiple node types',
{ triggerNode, webhookNode, pollNode },
['triggerNode', 'webhookNode', 'pollNode'],
false,
],
])('%s', (_, nodes: INodes, ignoredNodes: string[], expectedValid: boolean) => {
const result = validateWorkflowHasTriggerLikeNode(nodes, nodeTypes, ignoredNodes);
expect(result.isValid).toBe(expectedValid);
if (!expectedValid) {
expect(result.error).toBeDefined();
expect(result.error).toContain('no trigger node');
}
});
test('should return error message when no trigger nodes found', () => {
const nodes: INodes = { noTriggersNode };
const result = validateWorkflowHasTriggerLikeNode(nodes, nodeTypes);
expect(result.isValid).toBe(false);
expect(result.error).toBe(
'Workflow cannot be activated because it has no trigger node. At least one trigger, webhook, or polling node is required.',
);
});
});
File diff suppressed because it is too large Load Diff