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));
+ });
+});