diff --git a/packages/nodes-base/nodes/LocalFileTrigger/LocalFileTrigger.node.ts b/packages/nodes-base/nodes/LocalFileTrigger/LocalFileTrigger.node.ts index 67f7d43768..66ded2eabe 100644 --- a/packages/nodes-base/nodes/LocalFileTrigger/LocalFileTrigger.node.ts +++ b/packages/nodes-base/nodes/LocalFileTrigger/LocalFileTrigger.node.ts @@ -146,9 +146,9 @@ export class LocalFileTrigger implements INodeType { name: 'ignored', type: 'string', default: '', - placeholder: '**/*.txt', + placeholder: '**/*.txt or ignore-me/subfolder', description: - 'Files or paths to ignore. The whole path is tested, not just the filename. Supports Anymatch- syntax.', + "Files or paths to ignore. The whole path is tested, not just the filename. Supports Anymatch- syntax. Regex patterns may not work on macOS. To ignore files based on substring matching, use the 'Ignore Mode' option with 'Contain'.", }, { displayName: 'Ignore Existing Files/Folders', @@ -202,6 +202,27 @@ export class LocalFileTrigger implements INodeType { description: 'Whether to use polling for watching. Typically necessary to successfully watch files over a network.', }, + { + displayName: 'Ignore Mode', + name: 'ignoreMode', + type: 'options', + options: [ + { + name: 'Match', + value: 'match', + description: + 'Ignore files using regex patterns (e.g., **/*.txt), Not supported on macOS', + }, + { + name: 'Contain', + value: 'contain', + description: 'Ignore files if their path contains the specified value', + }, + ], + default: 'match', + description: + 'Whether to ignore files using regex matching (Anymatch patterns) or by checking if the path contains a specified value', + }, ], }, ], @@ -218,9 +239,9 @@ export class LocalFileTrigger implements INodeType { } else { events = this.getNodeParameter('events', []) as string[]; } - + const ignored = options.ignored === '' ? undefined : (options.ignored as string); const watcher = watch(path, { - ignored: options.ignored === '' ? undefined : (options.ignored as string), + ignored: options.ignoreMode === 'match' ? ignored : (x) => x.includes(ignored as string), persistent: true, ignoreInitial: options.ignoreInitial === undefined ? true : (options.ignoreInitial as boolean), diff --git a/packages/nodes-base/nodes/LocalFileTrigger/__test__/LocalFileTrigger.node.test.ts b/packages/nodes-base/nodes/LocalFileTrigger/__test__/LocalFileTrigger.node.test.ts new file mode 100644 index 0000000000..1b95bc3040 --- /dev/null +++ b/packages/nodes-base/nodes/LocalFileTrigger/__test__/LocalFileTrigger.node.test.ts @@ -0,0 +1,110 @@ +import chokidar from 'chokidar'; +import type { ITriggerFunctions } from 'n8n-workflow'; + +import { LocalFileTrigger } from '../LocalFileTrigger.node'; + +jest.mock('chokidar'); + +const mockWatcher = { + on: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), +}; + +(chokidar.watch as unknown as jest.Mock).mockReturnValue(mockWatcher); + +describe('LocalFileTrigger', () => { + let node: LocalFileTrigger; + let emitSpy: jest.Mock; + let context: ITriggerFunctions; + + beforeEach(() => { + node = new LocalFileTrigger(); + emitSpy = jest.fn(); + + context = { + getNodeParameter: jest.fn(), + emit: emitSpy, + helpers: { + returnJsonArray: (data: unknown[]) => data, + }, + } as unknown as ITriggerFunctions; + + jest.clearAllMocks(); + }); + + it('should set up chokidar with correct options for folder + match ignore', async () => { + (context.getNodeParameter as jest.Mock) + .mockReturnValueOnce('folder') + .mockReturnValueOnce('/some/folder') + .mockReturnValueOnce({ + ignored: '**/*.txt', + ignoreMode: 'match', + ignoreInitial: true, + followSymlinks: true, + depth: 1, + usePolling: false, + awaitWriteFinish: false, + }) + .mockReturnValueOnce(['add']); + + await node.trigger.call(context); + + expect(chokidar.watch).toHaveBeenCalledWith( + '/some/folder', + expect.objectContaining({ + ignored: '**/*.txt', + ignoreInitial: true, + depth: 1, + followSymlinks: true, + usePolling: false, + awaitWriteFinish: false, + }), + ); + + expect(mockWatcher.on).toHaveBeenCalledWith('add', expect.any(Function)); + }); + + it('should wrap ignored in function for ignoreMode=contain', async () => { + (context.getNodeParameter as jest.Mock) + .mockReturnValueOnce('folder') + .mockReturnValueOnce('/folder') + .mockReturnValueOnce({ + ignored: 'node_modules', + ignoreMode: 'contain', + }) + .mockReturnValueOnce(['change']); + + await node.trigger.call(context); + + const call = (chokidar.watch as jest.Mock).mock.calls[0][1]; + expect(typeof call.ignored).toBe('function'); + expect(call.ignored('folder/node_modules/stuff')).toBe(true); + expect(call.ignored('folder/src/index.js')).toBe(false); + }); + + it('should emit an event when a file changes', async () => { + (context.getNodeParameter as jest.Mock) + .mockReturnValueOnce('folder') + .mockReturnValueOnce('/watched') + .mockReturnValueOnce({}) + .mockReturnValueOnce(['change']); + + await node.trigger.call(context); + + const callback = mockWatcher.on.mock.calls.find(([event]) => event === 'change')?.[1]; + callback?.('/watched/file.txt'); + + expect(emitSpy).toHaveBeenCalledWith([[{ event: 'change', path: '/watched/file.txt' }]]); + }); + + it('should use "change" as the only event if watching a specific file', async () => { + (context.getNodeParameter as jest.Mock) + .mockReturnValueOnce('file') + .mockReturnValueOnce('/watched/file.txt') + .mockReturnValueOnce({}); + + await node.trigger.call(context); + + expect(mockWatcher.on).toHaveBeenCalledWith('change', expect.any(Function)); + }); +});