first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+9
View File
@@ -0,0 +1,9 @@
/**
* Asserts given condition
*/
export function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
// eslint-disable-next-line n8n-local-rules/no-plain-errors
throw new Error(message ?? 'Assertion failed');
}
}
+74
View File
@@ -0,0 +1,74 @@
import { createEventBus } from './event-bus';
describe('createEventBus()', () => {
const eventBus = createEventBus();
describe('on()', () => {
it('should register event handler', () => {
const handler = vi.fn();
const eventName = 'test';
eventBus.on(eventName, handler);
eventBus.emit(eventName, {});
expect(handler).toHaveBeenCalled();
});
});
describe('once()', () => {
it('should register event handler', () => {
const handler = vi.fn();
const eventName = 'test';
eventBus.once(eventName, handler);
eventBus.emit(eventName, {});
expect(handler).toHaveBeenCalled();
});
it('should unregister event handler after first call', () => {
const handler = vi.fn();
const eventName = 'test';
eventBus.once(eventName, handler);
eventBus.emit(eventName, {});
eventBus.emit(eventName, {});
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe('off()', () => {
it('should register event handler', () => {
const handler = vi.fn();
const eventName = 'test';
eventBus.on(eventName, handler);
eventBus.off(eventName, handler);
eventBus.emit(eventName, {});
expect(handler).not.toHaveBeenCalled();
});
});
describe('emit()', () => {
it('should call handlers with given event', () => {
const handlerA = vi.fn();
const handlerB = vi.fn();
const eventName = 'test';
const event = new Event(eventName);
eventBus.on(eventName, handlerA);
eventBus.on(eventName, handlerB);
eventBus.emit(eventName, event);
expect(handlerA).toHaveBeenCalledWith(event);
expect(handlerB).toHaveBeenCalledWith(event);
});
});
});
+84
View File
@@ -0,0 +1,84 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CallbackFn = (...args: any[]) => any;
type Payloads<ListenerMap> = {
[E in keyof ListenerMap]: unknown;
};
type Listener<Payload> = (payload: Payload) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface EventBus<ListenerMap extends Payloads<ListenerMap> = Record<string, any>> {
on<EventName extends keyof ListenerMap & string>(
eventName: EventName,
fn: Listener<ListenerMap[EventName]>,
): void;
once<EventName extends keyof ListenerMap & string>(
eventName: EventName,
fn: Listener<ListenerMap[EventName]>,
): void;
off<EventName extends keyof ListenerMap & string>(
eventName: EventName,
fn: Listener<ListenerMap[EventName]>,
): void;
emit<EventName extends keyof ListenerMap & string>(
eventName: EventName,
event?: ListenerMap[EventName],
): void;
}
/**
* Creates an event bus with the given listener map.
*
* @example
* ```ts
* const eventBus = createEventBus<{
* 'user-logged-in': { username: string };
* 'user-logged-out': never;
* }>();
*/
export function createEventBus<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ListenerMap extends Payloads<ListenerMap> = Record<string, any>,
>(): EventBus<ListenerMap> {
const handlers = new Map<string, CallbackFn[]>();
return {
on(eventName, fn) {
let eventFns = handlers.get(eventName);
if (!eventFns) {
eventFns = [fn];
} else {
eventFns.push(fn);
}
handlers.set(eventName, eventFns);
},
once(eventName, fn) {
const handler: typeof fn = (payload) => {
this.off(eventName, handler);
fn(payload);
};
this.on(eventName, handler);
},
off(eventName, fn) {
const eventFns = handlers.get(eventName);
if (eventFns) {
eventFns.splice(eventFns.indexOf(fn) >>> 0, 1);
}
},
emit(eventName, event) {
const eventFns = handlers.get(eventName);
if (eventFns) {
eventFns.slice().forEach((handler) => {
handler(event);
});
}
},
};
}
+100
View File
@@ -0,0 +1,100 @@
import { createEventQueue } from './event-queue';
describe('createEventQueue', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should process events in order', async () => {
const processedEvents: string[] = [];
// Create an async handler that pushes events into the processedEvents array.
const processEvent = vi.fn(async (event: string) => {
processedEvents.push(event);
// Simulate asynchronous delay of 10ms.
await new Promise((resolve) => setTimeout(resolve, 10));
});
// Create the event queue.
const { enqueue } = createEventQueue<string>(processEvent);
// Enqueue events in a specific order.
enqueue('Event 1');
enqueue('Event 2');
enqueue('Event 3');
// Advance the timers enough to process all events.
// runAllTimersAsync() will run all pending timers and wait for any pending promise resolution.
await vi.runAllTimersAsync();
expect(processEvent).toHaveBeenCalledTimes(3);
expect(processedEvents).toEqual(['Event 1', 'Event 2', 'Event 3']);
});
it('should handle errors and continue processing', async () => {
const processedEvents: string[] = [];
const processEvent = vi.fn(async (event: string) => {
if (event === 'fail') {
throw new Error('Processing error'); // eslint-disable-line n8n-local-rules/no-plain-errors
}
processedEvents.push(event);
await new Promise((resolve) => setTimeout(resolve, 10));
});
const { enqueue } = createEventQueue<string>(processEvent);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
enqueue('Event A');
enqueue('fail');
enqueue('Event B');
await vi.runAllTimersAsync();
expect(processEvent).toHaveBeenCalledTimes(3);
// 'fail' should cause an error but processing continues.
expect(processedEvents).toEqual(['Event A', 'Event B']);
expect(consoleSpy).toHaveBeenCalledWith('Error processing event:', expect.any(Error));
consoleSpy.mockRestore();
});
it('should not process any events if none are enqueued', async () => {
const processEvent = vi.fn(async (_event: string) => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
createEventQueue<string>(processEvent);
await vi.runAllTimersAsync();
// Did not enqueue any event.
expect(processEvent).not.toHaveBeenCalled();
});
it('should ensure no concurrent processing of events', async () => {
let processingCounter = 0;
let maxConcurrent = 0;
const processEvent = vi.fn(async (_event: string) => {
processingCounter++;
maxConcurrent = Math.max(maxConcurrent, processingCounter);
// Simulate asynchronous delay.
await new Promise((resolve) => setTimeout(resolve, 20));
processingCounter--;
});
const { enqueue } = createEventQueue<string>(processEvent);
enqueue('A');
enqueue('B');
enqueue('C');
await vi.runAllTimersAsync();
// Throughout processing, maxConcurrent should remain 1.
expect(maxConcurrent).toEqual(1);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Create an event queue that processes events sequentially.
*
* @param processEvent - Async function that processes a single event.
* @returns A function that enqueues events for processing.
*/
export function createEventQueue<T>(processEvent: (event: T) => Promise<void>) {
// The internal queue holding events.
const queue: T[] = [];
// Flag to indicate whether an event is currently being processed.
let processing = false;
/**
* Process the next event in the queue (if not already processing).
*/
async function processNext(): Promise<void> {
if (processing || queue.length === 0) {
return;
}
processing = true;
const currentEvent = queue.shift();
if (currentEvent !== undefined) {
try {
await processEvent(currentEvent);
} catch (error) {
console.error('Error processing event:', error);
}
}
processing = false;
// Recursively process the next event.
await processNext();
}
/**
* Enqueue an event and trigger processing.
*
* @param event - The event to enqueue.
*/
function enqueue(event: T): void {
queue.push(event);
void processNext();
}
return { enqueue };
}
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { isWindowsFilePath } from './path';
describe('isWindowsFilePath', () => {
describe('valid Windows paths', () => {
it('should return true for uppercase drive letter with forward slash', () => {
expect(isWindowsFilePath('C:/path')).toBe(true);
expect(isWindowsFilePath('Z:/')).toBe(true);
});
it('should return true for uppercase drive letter with backslash', () => {
expect(isWindowsFilePath('C:\\path')).toBe(true);
expect(isWindowsFilePath('Z:\\')).toBe(true);
});
it('should return true for lowercase drive letter with forward slash', () => {
expect(isWindowsFilePath('c:/path')).toBe(true);
expect(isWindowsFilePath('z:/')).toBe(true);
});
it('should return true for lowercase drive letter with backslash', () => {
expect(isWindowsFilePath('c:\\path')).toBe(true);
expect(isWindowsFilePath('z:\\')).toBe(true);
});
});
describe('invalid paths', () => {
it('should return false for Unix/Linux absolute paths', () => {
expect(isWindowsFilePath('/unix/path')).toBe(false);
});
it('should return false for Unix/Linux relative paths', () => {
expect(isWindowsFilePath('./relative/path')).toBe(false);
expect(isWindowsFilePath('../parent/path')).toBe(false);
expect(isWindowsFilePath('relative/path')).toBe(false);
});
it('should return false for UNC network paths', () => {
expect(isWindowsFilePath('//network/share')).toBe(false);
expect(isWindowsFilePath('\\\\network\\share')).toBe(false);
});
it('should return false for empty string', () => {
expect(isWindowsFilePath('')).toBe(false);
});
it('should return false for paths missing drive letter separator', () => {
expect(isWindowsFilePath('C')).toBe(false);
expect(isWindowsFilePath('C:')).toBe(false);
expect(isWindowsFilePath('CD:/path')).toBe(false);
});
it('should return false for paths with invalid drive letter format', () => {
expect(isWindowsFilePath('1:/path')).toBe(false);
expect(isWindowsFilePath('@:/path')).toBe(false);
});
});
});
+6
View File
@@ -0,0 +1,6 @@
/**
* Fast check if file path starts with a windows drive letter, e.g. 'C:/' or 'C:\\'
*/
export function isWindowsFilePath(str: string) {
return /^[a-zA-Z]:[\\/]/.test(str);
}
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFilename } from './sanitize';
describe('sanitizeFilename', () => {
it('should return normal filenames unchanged', () => {
expect(sanitizeFilename('normalfile')).toBe('normalfile');
expect(sanitizeFilename('my-file_v2')).toBe('my-file_v2');
});
it('should handle empty and invalid inputs', () => {
expect(sanitizeFilename('')).toBe('untitled');
expect(sanitizeFilename(null as unknown as string)).toBe('untitled');
expect(sanitizeFilename(undefined as unknown as string)).toBe('untitled');
expect(sanitizeFilename('filename ')).toBe('filename');
});
it('should replace forbidden characters', () => {
expect(sanitizeFilename('hello:world')).toBe('hello_world');
expect(sanitizeFilename('file<name>')).toBe('file_name_');
expect(sanitizeFilename('file/name')).toBe('file_name');
expect(sanitizeFilename('file|name')).toBe('file_name');
});
it('should handle Unicode characters', () => {
expect(sanitizeFilename('file\u200Bname')).toBe('filename'); // Zero-width space
expect(sanitizeFilename('file\u00A0name')).toBe('file name'); // Non-breaking space
});
it('should handle edge cases', () => {
expect(sanitizeFilename('.')).toBe('untitled');
expect(sanitizeFilename('..')).toBe('untitled');
expect(sanitizeFilename(' ... ')).toBe('untitled');
});
it('should handle length limits', () => {
const longName = 'a'.repeat(250);
const result = sanitizeFilename(longName, 50);
expect(result.length).toBeLessThanOrEqual(50);
});
// 15 most complex world languages (by writing system complexity)
it('should support complex writing systems', () => {
// 1. Arabic - Right-to-left, complex ligatures
expect(sanitizeFilename('سير العمل الخاص بي')).toBe('سير العمل الخاص بي');
// 2. Burmese - Complex script with stacked characters
expect(sanitizeFilename('ကျွန်ုပ်၏ လုပ်ငန်းစဉ်')).toBe('ကျွန်ုပ်၏ လုပ်ငန်းစဉ်');
// 3. Thai - Complex script, no word separators
expect(sanitizeFilename('เวิร์กโฟลว์ของฉัน')).toBe('เวิร์กโฟลว์ของฉัน');
// 4. Hindi - Devanagari script with complex conjuncts
expect(sanitizeFilename('मेरा वर्कफ़्लो')).toBe('मेरा वर्कफ़्लो');
// 5. Bengali - Complex script with conjunct consonants
expect(sanitizeFilename('আমার ওয়ার্কফ্লো')).toBe('আমার ওয়ার্কফ্লো');
// 6. Urdu - Right-to-left, Arabic-based script
expect(sanitizeFilename('میرا ورک فلو')).toBe('میرا ورک فلو');
// 7. Chinese - Logographic writing system
expect(sanitizeFilename('我的工作流')).toBe('我的工作流');
// 8. Japanese - Mixed scripts (Hiragana, Katakana, Kanji)
expect(sanitizeFilename('私のワークフロー')).toBe('私のワークフロー');
// 9. Korean - Hangul syllabic blocks
expect(sanitizeFilename('내 워크플로우')).toBe('내 워크플로우');
// 10. Russian - Cyrillic script
expect(sanitizeFilename('Мой рабочий процесс')).toBe('Мой рабочий процесс');
// 11. Tamil - Complex script with vowel marks
expect(sanitizeFilename('எனது பணிப்பாய்வு')).toBe('எனது பணிப்பாய்வு');
// 12. Telugu - Complex script with conjunct consonants
expect(sanitizeFilename('నా వర్క్‌ఫ్లో')).toBe('నా వర్క్ఫ్లో');
// 13. Marathi - Devanagari script
expect(sanitizeFilename('माझा वर्कफ्लो')).toBe('माझा वर्कफ्लो');
// 14. Gujarati - Complex script with vowel modifications
expect(sanitizeFilename('મારો વર્કફ્લો')).toBe('મારો વર્કફ્લો');
// 15. Punjabi - Gurmukhi script
expect(sanitizeFilename('ਮੇਰਾ ਵਰਕਫਲੋ')).toBe('ਮੇਰਾ ਵਰਕਫਲੋ');
});
it('should handle mixed complex scripts with special characters', () => {
expect(sanitizeFilename('工作流程/ワークフロー')).toBe('工作流程_ワークフロー');
expect(sanitizeFilename('वर्कफ्लो:العمل')).toBe('वर्कफ्लो_العمل');
expect(sanitizeFilename('프로세스|процесс')).toBe('프로세스_процесс');
});
});
+87
View File
@@ -0,0 +1,87 @@
// Constants definition
/* eslint-disable no-control-regex */
const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g;
const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g;
const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g;
const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g;
/* eslint-enable no-control-regex */
const WINDOWS_RESERVED_NAMES = new Set([
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
]);
const DEFAULT_FALLBACK_NAME = 'untitled';
const MAX_FILENAME_LENGTH = 200;
/**
* Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems
*
* Main features:
* - Replace invalid characters (e.g. ":" in hello:world)
* - Handle Windows reserved names
* - Limit filename length
* - Normalize Unicode characters
*
* @param filename - The filename to sanitize (without extension)
* @param maxLength - Maximum filename length (default: 200)
* @returns A sanitized filename (without extension)
*
* @example
* sanitizeFilename('hello:world') // returns 'hello_world'
* sanitizeFilename('CON') // returns '_CON'
* sanitizeFilename('') // returns 'untitled'
*/
export const sanitizeFilename = (
filename: string,
maxLength: number = MAX_FILENAME_LENGTH,
): string => {
// Input validation
if (!filename) {
return DEFAULT_FALLBACK_NAME;
}
let baseName = filename
.trim()
.replace(INVALID_CHARS_REGEX, '_')
.replace(ZERO_WIDTH_CHARS_REGEX, '')
.replace(UNICODE_SPACES_REGEX, ' ')
.replace(LEADING_TRAILING_DOTS_SPACES_REGEX, '');
// Handle empty or invalid filenames after cleaning
if (!baseName) {
baseName = DEFAULT_FALLBACK_NAME;
}
// Handle Windows reserved names
if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) {
baseName = `_${baseName}`;
}
// Truncate if too long
if (baseName.length > maxLength) {
baseName = baseName.slice(0, maxLength);
}
return baseName;
};
+12
View File
@@ -0,0 +1,12 @@
export * from './assert';
export * from './event-bus';
export * from './event-queue';
export * from './retry';
export * from './workflowId';
export * from './number/smartDecimal';
export * from './search/reRankSearchResults';
export * from './search/sublimeSearch';
export * from './sort/sortByProperty';
export * from './string/truncate';
export * from './files/sanitize';
export * from './files/path';
@@ -0,0 +1,35 @@
import { smartDecimal } from './smartDecimal';
describe('smartDecimal', () => {
it('should return the same value if it is an integer', () => {
expect(smartDecimal(42)).toBe(42);
});
it('should return the same value if it has only one decimal place', () => {
expect(smartDecimal(42.5)).toBe(42.5);
});
it('should round to two decimal places by default', () => {
expect(smartDecimal(42.567)).toBe(42.57);
});
it('should round to the specified number of decimal places', () => {
expect(smartDecimal(42.567, 1)).toBe(42.6);
});
it('should handle negative numbers correctly', () => {
expect(smartDecimal(-42.567, 2)).toBe(-42.57);
});
it('should handle zero correctly', () => {
expect(smartDecimal(0)).toBe(0);
});
it('should handle very small numbers correctly', () => {
expect(smartDecimal(0.000567, 5)).toBe(0.00057);
});
it('should round to two decimal if it is smaller than the given one', () => {
expect(smartDecimal(42.56, 3)).toBe(42.56);
});
});
@@ -0,0 +1,13 @@
export const smartDecimal = (value: number, decimals = 2): number => {
// Check if integer
if (Number.isInteger(value)) {
return value;
}
// Check if it has only one decimal place
if (value.toString().split('.')[1].length <= decimals) {
return value;
}
return Number(value.toFixed(decimals));
};
+122
View File
@@ -0,0 +1,122 @@
import { retry } from './retry';
describe('retry', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllTimers();
});
it('should resolve true when the function eventually returns true', async () => {
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
// Return true on the second attempt.
return callCount === 2;
});
const promise = retry(fn, 1000, 2, null);
// The first call happens immediately.
expect(fn).toHaveBeenCalledTimes(1);
// Advance timers by 1000ms asynchronously to allow the waiting period to complete.
await vi.advanceTimersByTimeAsync(1000);
// After advancing, the second attempt should have occurred.
expect(fn).toHaveBeenCalledTimes(2);
// The promise should now resolve with true.
const result = await promise;
expect(result).toBe(true);
});
it('should resolve false if maximum retries are reached with no success', async () => {
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
return false;
});
const promise = retry(fn, 1000, 3, null);
// The first attempt fires immediately.
expect(fn).toHaveBeenCalledTimes(1);
// Advance timers for the delay after the first attempt.
await vi.advanceTimersByTimeAsync(1000);
expect(fn).toHaveBeenCalledTimes(2);
// Advance timers for the delay after the second attempt.
await vi.advanceTimersByTimeAsync(1000);
expect(fn).toHaveBeenCalledTimes(3);
// With maxRetries reached (3 calls), promise should resolve to false.
const result = await promise;
expect(result).toBe(false);
});
it('should reject if the function throws an error', async () => {
const fn = vi.fn(async () => {
throw new Error('Test error'); // eslint-disable-line n8n-local-rules/no-plain-errors
});
// Since the error is thrown on the first call, no timer advancement is needed.
await expect(retry(fn, 1000, 3, null)).rejects.toThrow('Test error');
expect(fn).toHaveBeenCalledTimes(1);
});
it('should use linear backoff strategy', async () => {
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
return callCount === 4; // Return true on the fourth attempt.
});
const promise = retry(fn, 1000, 4, 'linear');
expect(fn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000); // First backoff
expect(fn).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(2000); // Second backoff
expect(fn).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(3000); // Third backoff
expect(fn).toHaveBeenCalledTimes(4);
const result = await promise;
expect(result).toBe(true);
});
it('should use exponential backoff strategy', async () => {
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
return callCount === 5; // Return true on the fifth attempt.
});
const promise = retry(fn, 1000, 5, 'exponential');
expect(fn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000); // First backoff
expect(fn).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(2000); // Second backoff
expect(fn).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(4000); // Third backoff
expect(fn).toHaveBeenCalledTimes(4);
await vi.advanceTimersByTimeAsync(8000); // Fourth backoff
expect(fn).toHaveBeenCalledTimes(5);
const result = await promise;
expect(result).toBe(true);
});
});
+51
View File
@@ -0,0 +1,51 @@
type RetryFn = () => boolean | Promise<boolean>;
/**
* A utility that retries a function every `interval` milliseconds
* until the function returns true or the maximum number of retries is reached.
*
* @param fn - A function that returns a boolean or a Promise resolving to a boolean.
* @param interval - The time interval (in milliseconds) between each retry. Defaults to 1000.
* @param maxRetries - The maximum number of retry attempts. Defaults to 3.
* @param backoff - The backoff strategy to use: 'linear', 'exponential', or null.
* @returns {Promise<boolean>} - A promise that resolves to:
* - true: If the function returns true before reaching maxRetries.
* - false: If the function never returns true or if an error occurs.
*/
export async function retry(
fn: RetryFn,
interval: number = 1000,
maxRetries: number = 3,
backoff: 'exponential' | 'linear' | null = 'linear',
): Promise<boolean> {
let attempt = 0;
while (attempt < maxRetries) {
attempt++;
try {
const result = await fn();
if (result) {
return true;
}
} catch (error) {
console.error('Error during retry:', error);
throw error;
}
// Wait for the specified interval before the next attempt, if any attempts remain.
if (attempt < maxRetries) {
let computedInterval = interval;
if (backoff === 'linear') {
computedInterval = interval * attempt;
} else if (backoff === 'exponential') {
computedInterval = Math.pow(2, attempt - 1) * interval;
computedInterval = Math.min(computedInterval, 30000); // Cap the maximum interval to 30 seconds
}
await new Promise<void>((resolve) => setTimeout(resolve, computedInterval));
}
}
return false;
}
@@ -0,0 +1,119 @@
import { reRankSearchResults } from './reRankSearchResults';
import topLevel from './snapshots/toplevel.snapshot.json';
import { sublimeSearch } from './sublimeSearch';
describe('reRankSearchResults', () => {
describe('should re-rank search results based on additional factors', () => {
it('should return Coda before Code without additional factors for query "cod"', () => {
const searchResults = sublimeSearch('cod', topLevel);
const resultNames = searchResults.map((result) => result.item.properties.displayName);
// Without re-ranking, Coda should appear before Code
expect(resultNames[0]).toBe('Coda');
expect(resultNames[1]).toBe('Code');
});
it('should return Code before Coda with additional factors favoring Code for query "cod"', () => {
const searchResults = sublimeSearch('cod', topLevel);
// Add popularity scores that heavily favor Code node
const additionalFactors = {
popularity: {
/* eslint-disable @typescript-eslint/naming-convention */
'n8n-nodes-base.code': 90, // High popularity for Code node
'n8n-nodes-base.coda': 10, // Lower popularity for Coda node
/* eslint-enable @typescript-eslint/naming-convention */
},
};
const reRankedResults = reRankSearchResults(searchResults, additionalFactors);
const resultNames = reRankedResults.map((result) => result.item.properties.displayName);
// After re-ranking with additional factors, Code should appear before Coda
expect(resultNames[0]).toBe('Code');
expect(resultNames[1]).toBe('Coda');
});
it('should handle multiple additional factors', () => {
const searchResults = sublimeSearch('cod', topLevel);
// Add multiple factors: popularity and recent usage
const additionalFactors = {
popularity: {
/* eslint-disable @typescript-eslint/naming-convention */
'n8n-nodes-base.code': 50,
'n8n-nodes-base.coda': 40,
/* eslint-enable @typescript-eslint/naming-convention */
},
recentUsage: {
/* eslint-disable @typescript-eslint/naming-convention */
'n8n-nodes-base.code': 80, // Code was used more recently
'n8n-nodes-base.coda': 20,
/* eslint-enable @typescript-eslint/naming-convention */
},
};
const reRankedResults = reRankSearchResults(searchResults, additionalFactors);
const resultNames = reRankedResults.map((result) => result.item.properties.displayName);
// Code should rank higher due to combined score (50 + 80 = 130 vs Coda's 40 + 20 = 60)
expect(resultNames[0]).toBe('Code');
expect(resultNames[1]).toBe('Coda');
});
it('should preserve original order when additional factors are equal', () => {
const searchResults = sublimeSearch('cod', topLevel);
// Add equal factors for both nodes
const additionalFactors = {
popularity: {
/* eslint-disable @typescript-eslint/naming-convention */
'n8n-nodes-base.code': 50,
'n8n-nodes-base.coda': 50,
/* eslint-enable @typescript-eslint/naming-convention */
},
};
const reRankedResults = reRankSearchResults(searchResults, additionalFactors);
const resultNames = reRankedResults.map((result) => result.item.properties.displayName);
// When additional factors are equal, original order should be preserved
// Since Coda has a higher base score from sublimeSearch, it should remain first
expect(resultNames[0]).toBe('Coda');
expect(resultNames[1]).toBe('Code');
});
it('should handle empty additional factors object', () => {
const searchResults = sublimeSearch('cod', topLevel);
const reRankedResults = reRankSearchResults(searchResults, {});
// Results should be identical to original search results
expect(reRankedResults).toEqual(searchResults);
});
it('should handle nodes not present in additional factors', () => {
const searchResults = sublimeSearch('git', topLevel);
// Only provide factor for some items
const additionalFactors = {
popularity: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'n8n-nodes-base.github': 100,
// Other git-related nodes are not included
},
};
const reRankedResults = reRankSearchResults(searchResults, additionalFactors);
// GitHub should rank higher due to additional factor
const githubIndex = reRankedResults.findIndex(
(r) => r.item.properties.displayName === 'GitHub',
);
const gitIndex = reRankedResults.findIndex((r) => r.item.properties.displayName === 'Git');
if (githubIndex !== -1 && gitIndex !== -1) {
expect(githubIndex).toBeLessThan(gitIndex);
}
});
});
});
@@ -0,0 +1,26 @@
export function reRankSearchResults<T extends { key: string }>(
searchResults: Array<{ score: number; item: T }>,
additionalFactors: Record<string, Record<string, number>>,
): Array<{ score: number; item: T }> {
return searchResults
.map(({ score, item }) => {
// For each additional factor, we check if it exists for the item and type,
// and if so, we add the score to the item's score.
const additionalScore = Object.entries(additionalFactors).reduce((acc, [_, factorScores]) => {
const factorScore = factorScores[item.key];
if (factorScore) {
return acc + factorScore;
}
return acc;
}, 0);
return {
score: score + additionalScore,
item,
};
})
.sort((a, b) => {
return b.score - a.score;
});
}
@@ -0,0 +1,13 @@
# Search snapshots
This directory contains snapshots containing real data fed into the sublimeSearch function.
These were obtained via `console.log(items)` right before the sublimeSearch call in `editor-ui` (currently in `packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts`)
Which is triggered by typing in the search bar in varying states of the application:
- toplevel: From an empty workflow (so missing e.g. tools)
After typing in the search bar you should see an object in the console you can copy via `Right Click->Copy Object" which will cleanly paste to json.
**Please use Chrome for capturing these - the recovered object in Chrome is about 3x larger than in Firefox due to Firefox dropping some nested values**
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
import topLevel from './snapshots/toplevel.snapshot.json';
import { sublimeSearch } from './sublimeSearch';
describe('sublimeSearch', () => {
describe('search finds specific matches first', () => {
// Note that this only tests the order of the specified matches
// Further results may appear after the listed ones
const testCases: Array<[string, string[]]> = [
['set', ['Edit Fields (Set)']],
['agent', ['AI Agent', 'Magento 2']],
];
test.each(testCases)(
'should return at least "$expectedOrder" for filter "$filter"',
(filter, expectedOrder) => {
// These match the weights in the production use case
const results = sublimeSearch(filter, topLevel);
const resultNames = results.map((result) => result.item.properties.displayName);
expectedOrder.forEach((expectedName, index) => {
expect(resultNames[index]).toBe(expectedName);
});
},
);
});
});
@@ -0,0 +1,282 @@
/*
* Constants and utility functions used for searching for node types in node creator component
* based on https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js
*/
const SEQUENTIAL_BONUS = 60; // bonus for adjacent matches
const SEPARATOR_BONUS = 38; // bonus if match occurs after a separator
const CAMEL_BONUS = 30; // bonus if match is uppercase and prev is lower
const FIRST_LETTER_BONUS = 15; // bonus if the first letter is matched
const LEADING_LETTER_PENALTY = -20; // penalty applied for every letter in str before the first match
const MAX_LEADING_LETTER_PENALTY = -200; // maximum penalty for leading letters
const UNMATCHED_LETTER_PENALTY = -5;
export const DEFAULT_KEYS = [
{ key: 'properties.displayName', weight: 1.3 },
{ key: 'properties.codex.alias', weight: 1 },
];
/**
* Returns true if each character in pattern is found sequentially within target
* @param {*} pattern string
* @param {*} target string
*/
function fuzzyMatchSimple(pattern: string, target: string): boolean {
let patternIdx = 0;
let strIdx = 0;
while (patternIdx < pattern.length && strIdx < target.length) {
const patternChar = pattern.charAt(patternIdx).toLowerCase();
const targetChar = target.charAt(strIdx).toLowerCase();
if (patternChar === targetChar) {
patternIdx++;
}
++strIdx;
}
return pattern.length !== 0 && target.length !== 0 && patternIdx === pattern.length;
}
function fuzzyMatchRecursive(
pattern: string,
target: string,
patternCurIndex: number,
targetCurrIndex: number,
targetMatches: null | number[],
matches: number[],
maxMatches: number,
nextMatch: number,
recursionCount: number,
recursionLimit: number,
): { matched: boolean; outScore: number } {
let outScore = 0;
// Return if recursion limit is reached.
if (++recursionCount >= recursionLimit) {
return { matched: false, outScore };
}
// Return if we reached ends of strings.
if (patternCurIndex === pattern.length || targetCurrIndex === target.length) {
return { matched: false, outScore };
}
// Recursion params
let recursiveMatch = false;
let bestRecursiveMatches: number[] = [];
let bestRecursiveScore = 0;
// Loop through pattern and str looking for a match.
let firstMatch = true;
while (patternCurIndex < pattern.length && targetCurrIndex < target.length) {
// Match found.
if (pattern[patternCurIndex].toLowerCase() === target[targetCurrIndex].toLowerCase()) {
if (nextMatch >= maxMatches) {
return { matched: false, outScore };
}
if (firstMatch && targetMatches) {
matches = [...targetMatches];
firstMatch = false;
}
const recursiveMatches: number[] = [];
const recursiveResult = fuzzyMatchRecursive(
pattern,
target,
patternCurIndex,
targetCurrIndex + 1,
matches,
recursiveMatches,
maxMatches,
nextMatch,
recursionCount,
recursionLimit,
);
const recursiveScore = recursiveResult.outScore;
if (recursiveResult.matched) {
// Pick best recursive score.
if (!recursiveMatch || recursiveScore > bestRecursiveScore) {
bestRecursiveMatches = [...recursiveMatches];
bestRecursiveScore = recursiveScore;
}
recursiveMatch = true;
}
matches[nextMatch++] = targetCurrIndex;
++patternCurIndex;
}
++targetCurrIndex;
}
const matched = patternCurIndex === pattern.length;
if (matched) {
outScore = 100;
// Apply leading letter penalty (if not n8n-prefixed)
if (!target.toLowerCase().startsWith('n8n')) {
let penalty = LEADING_LETTER_PENALTY * matches[0];
penalty = penalty < MAX_LEADING_LETTER_PENALTY ? MAX_LEADING_LETTER_PENALTY : penalty;
outScore += penalty;
}
//Apply unmatched penalty
const unmatched = target.length - nextMatch;
outScore += UNMATCHED_LETTER_PENALTY * unmatched;
// Apply ordering bonuses
for (let i = 0; i < nextMatch; i++) {
const currIdx = matches[i];
if (i > 0) {
const prevIdx = matches[i - 1];
if (currIdx === prevIdx + 1) {
outScore += SEQUENTIAL_BONUS;
}
}
// Check for bonuses based on neighbor character value.
if (currIdx > 0) {
// Camel case
const neighbor = target[currIdx - 1];
const curr = target[currIdx];
if (neighbor !== neighbor.toUpperCase() && curr !== curr.toLowerCase()) {
outScore += CAMEL_BONUS;
}
const isNeighbourSeparator = neighbor === '_' || neighbor === ' ';
if (isNeighbourSeparator) {
outScore += SEPARATOR_BONUS;
}
} else {
// First letter
outScore += FIRST_LETTER_BONUS;
}
}
// Return best result
if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) {
// Recursive score is better than "this"
matches = [...bestRecursiveMatches];
outScore = bestRecursiveScore;
return { matched: true, outScore };
} else if (matched) {
// "this" score is better than recursive
return { matched: true, outScore };
} else {
return { matched: false, outScore };
}
}
return { matched: false, outScore };
}
/**
* Does a fuzzy search to find pattern inside a string.
* @param {*} pattern string pattern to search for
* @param {*} target string string which is being searched
* @returns [boolean, number] a boolean which tells if pattern was
* found or not and a search score
*/
function fuzzyMatch(pattern: string, target: string): { matched: boolean; outScore: number } {
const recursionCount = 0;
const recursionLimit = 5;
const matches: number[] = [];
const maxMatches = 256;
return fuzzyMatchRecursive(
pattern,
target,
0 /* patternCurIndex */,
0 /* strCurrIndex */,
null /* srcMatces */,
matches,
maxMatches,
0 /* nextMatch */,
recursionCount,
recursionLimit,
);
}
// prop = 'key'
// prop = 'key1.key2'
// prop = ['key1', 'key2']
function getValue<T extends object>(obj: T, prop: string): unknown {
if (obj.hasOwnProperty(prop)) {
return obj[prop as keyof T];
}
const segments = prop.split('.');
let result = obj;
let i = 0;
while (result && i < segments.length) {
const key = segments[i] as keyof T;
result = result[key] as T;
i++;
}
return result;
}
export function sublimeSearch<T extends object>(
filter: string,
data: readonly T[],
keys: Array<{ key: string; weight: number }> = DEFAULT_KEYS,
): Array<{ score: number; item: T }> {
const results = data.reduce((accu: Array<{ score: number; item: T }>, item: T) => {
let values: Array<{ value: string; weight: number }> = [];
keys.forEach(({ key, weight }) => {
const value = getValue(item, key);
if (Array.isArray(value)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
values = values.concat(value.map((v) => ({ value: v, weight })));
} else if (typeof value === 'string') {
values.push({
value,
weight,
});
}
});
// for each item, check every key and get maximum score
const itemMatch = values.reduce(
(
result: null | { matched: boolean; outScore: number },
{ value, weight }: { value: string; weight: number },
) => {
if (!fuzzyMatchSimple(filter, value)) {
return result;
}
const match = fuzzyMatch(filter, value);
match.outScore *= weight;
const { matched, outScore } = match;
if (!result && matched) {
return match;
}
if (matched && result && outScore > result.outScore) {
return match;
}
return result;
},
null,
);
if (itemMatch) {
accu.push({
score: itemMatch.outScore,
item,
});
}
return accu;
}, []);
results.sort((a, b) => {
return b.score - a.score;
});
return results;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,50 @@
import { sortByProperty } from './sortByProperty';
const arrayOfObjects = [
{ name: 'Álvaro', age: 30 },
{ name: 'Élodie', age: 28 },
{ name: 'Željko', age: 25 },
{ name: 'Bob', age: 35 },
];
describe('sortByProperty', () => {
it('should sort an array of objects by a property', () => {
const sortedArray = sortByProperty('name', arrayOfObjects);
expect(sortedArray).toEqual([
{ name: 'Álvaro', age: 30 },
{ name: 'Bob', age: 35 },
{ name: 'Élodie', age: 28 },
{ name: 'Željko', age: 25 },
]);
});
it('should sort an array of objects by a property in descending order', () => {
const sortedArray = sortByProperty('name', arrayOfObjects, 'desc');
expect(sortedArray).toEqual([
{ name: 'Željko', age: 25 },
{ name: 'Élodie', age: 28 },
{ name: 'Bob', age: 35 },
{ name: 'Álvaro', age: 30 },
]);
});
it('should sort an array of objects by a property if its number', () => {
const sortedArray = sortByProperty('age', arrayOfObjects);
expect(sortedArray).toEqual([
{ name: 'Željko', age: 25 },
{ name: 'Élodie', age: 28 },
{ name: 'Álvaro', age: 30 },
{ name: 'Bob', age: 35 },
]);
});
it('should sort an array of objects by a property in descending order if its number', () => {
const sortedArray = sortByProperty('age', arrayOfObjects, 'desc');
expect(sortedArray).toEqual([
{ name: 'Bob', age: 35 },
{ name: 'Álvaro', age: 30 },
{ name: 'Élodie', age: 28 },
{ name: 'Željko', age: 25 },
]);
});
});
@@ -0,0 +1,12 @@
export const sortByProperty = <T>(
property: keyof T,
arr: T[],
order: 'asc' | 'desc' = 'asc',
): T[] =>
arr.sort((a, b) => {
const result = String(a[property]).localeCompare(String(b[property]), undefined, {
numeric: true,
sensitivity: 'base',
});
return order === 'asc' ? result : -result;
});
@@ -0,0 +1,49 @@
import { truncateBeforeLast, truncate } from './truncate';
describe('truncate', () => {
it('should truncate text to 30 chars by default', () => {
expect(truncate('This is a very long text that should be truncated')).toBe(
'This is a very long text that ...',
);
});
it('should truncate text to given length', () => {
expect(truncate('This is a very long text that should be truncated', 25)).toBe(
'This is a very long text ...',
);
});
});
describe(truncateBeforeLast, () => {
it('should return unmodified text if the length does not exceed max length', () => {
expect(truncateBeforeLast('I love nodemation', 25)).toBe('I love nodemation');
expect(truncateBeforeLast('I ❤️ nodemation', 25)).toBe('I ❤️ nodemation');
expect(truncateBeforeLast('Nodemation is cool', 25)).toBe('Nodemation is cool');
expect(truncateBeforeLast('Internationalization', 25)).toBe('Internationalization');
expect(truncateBeforeLast('I love 👨‍👩‍👧‍👦', 8)).toBe('I love 👨‍👩‍👧‍👦');
});
it('should remove chars just before the last word, as long as the last word is under 15 chars', () => {
expect(truncateBeforeLast('I love nodemation', 15)).toBe('I lo…nodemation');
expect(truncateBeforeLast('I love "nodemation"', 15)).toBe('I …"nodemation"');
expect(truncateBeforeLast('I ❤️ nodemation', 13)).toBe('I …nodemation');
expect(truncateBeforeLast('Nodemation is cool', 15)).toBe('Nodemation…cool');
expect(truncateBeforeLast('"Nodemation" is cool', 15)).toBe('"Nodematio…cool');
expect(truncateBeforeLast('Is it fun to automate boring stuff?', 15)).toBe('Is it fu…stuff?');
expect(truncateBeforeLast('Is internationalization fun?', 15)).toBe('Is interna…fun?');
expect(truncateBeforeLast('I love 👨‍👩‍👧‍👦', 7)).toBe('I lov…👨‍👩‍👧‍👦');
});
it('should preserve last 5 characters if the last word is longer than 15 characters', () => {
expect(truncateBeforeLast('I love internationalization', 25)).toBe('I love internationa…ation');
expect(truncateBeforeLast('I love "internationalization"', 25)).toBe(
'I love "internation…tion"',
);
expect(truncateBeforeLast('I "love" internationalization', 25)).toBe(
'I "love" internatio…ation',
);
expect(truncateBeforeLast('I ❤️ internationalization', 9)).toBe('I ❤️…ation');
expect(truncateBeforeLast('I ❤️ internationalization', 8)).toBe('I …ation');
expect(truncateBeforeLast('Internationalization', 15)).toBe('Internati…ation');
});
});
@@ -0,0 +1,54 @@
export const truncate = (text: string, length = 30): string =>
text.length > length ? text.slice(0, length) + '...' : text;
/**
* Replace part of given text with ellipsis following the rules below:
*
* - Remove chars just before the last word, as long as the last word is under 15 chars
* - Otherwise preserve the last 5 chars of the name and remove chars before that
*/
export function truncateBeforeLast(
text: string,
maxLength: number,
lastCharsLength: number = 5,
): string {
const chars: string[] = [];
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
for (const { segment } of segmenter.segment(text)) {
chars.push(segment);
}
if (chars.length <= maxLength) {
return text;
}
const lastWhitespaceIndex = chars.findLastIndex((ch) => ch.match(/^\s+$/));
const lastWordIndex = lastWhitespaceIndex + 1;
const lastWord = chars.slice(lastWordIndex);
const ellipsis = '…';
const ellipsisLength = ellipsis.length;
if (lastWord.length < 15) {
const charsToRemove = chars.length - maxLength + ellipsisLength;
const indexBeforeLastWord = lastWordIndex;
const keepLength = indexBeforeLastWord - charsToRemove;
if (keepLength > 0) {
return (
chars.slice(0, keepLength).join('') + ellipsis + chars.slice(indexBeforeLastWord).join('')
);
}
}
if (lastCharsLength < 1) {
return chars.slice(0, maxLength).join('') + ellipsis;
}
return (
chars.slice(0, maxLength - lastCharsLength - ellipsisLength).join('') +
ellipsis +
chars.slice(-lastCharsLength).join('')
);
}
+24
View File
@@ -0,0 +1,24 @@
import { NANOID_ALPHABET } from '@n8n/constants';
import { customAlphabet } from 'nanoid';
/**
* Generates a unique 16-character nanoid.
*
* This is the canonical ID generator used across the entire n8n codebase for:
* - Workflow IDs
* - Project IDs
* - Variable IDs
* - API Key IDs
* - And other entity IDs
*
* Both frontend and backend MUST use this function to ensure consistency.
*
* @returns A 16-character ID
*
* @example
* ```ts
* const id = generateNanoId();
* // => 'aBcDeFgHiJkLmNoP' (16 characters)
* ```
*/
export const generateNanoId = customAlphabet(NANOID_ALPHABET, 16);