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
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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": []
|
||||
}
|
||||
+221
@@ -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"
|
||||
}
|
||||
+114
@@ -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": {}
|
||||
}
|
||||
+215
@@ -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"
|
||||
}
|
||||
+104
@@ -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": {}
|
||||
}
|
||||
+62
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+199
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+203
@@ -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"
|
||||
}
|
||||
+84
@@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+216
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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)])],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user