diff --git a/packages/@n8n/node-cli/src/commands/dev/utils.test.ts b/packages/@n8n/node-cli/src/commands/dev/utils.test.ts index 2535ef606ba..54cf62e49be 100644 --- a/packages/@n8n/node-cli/src/commands/dev/utils.test.ts +++ b/packages/@n8n/node-cli/src/commands/dev/utils.test.ts @@ -1,5 +1,5 @@ import { type ChildProcess, execSync, spawn } from 'node:child_process'; -import { existsSync, type FSWatcher, watch } from 'node:fs'; +import { type FSWatcher, statSync, watch } from 'node:fs'; import path from 'node:path'; import { @@ -41,6 +41,13 @@ function createFakeWatcher() { return { unref: vi.fn(), close: vi.fn() }; } +function mockDirs(isDir: (target: string) => boolean): void { + vi.mocked(statSync).mockImplementation((target) => { + if (!isDir(String(target))) throw new Error('ENOENT'); + return { isDirectory: () => true } as unknown as ReturnType; + }); +} + describe('dev utils', () => { describe('sleep', () => { beforeEach(() => { @@ -301,7 +308,7 @@ describe('dev utils', () => { }); it('should watch only the asset directories, never node_modules or the cwd', () => { - vi.mocked(existsSync).mockReturnValue(true); + mockDirs(() => true); vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher); watchStaticFiles(vi.fn()); @@ -316,20 +323,67 @@ describe('dev utils', () => { expect(watched.some((dir) => String(dir).includes('node_modules'))).toBe(false); }); - it('should skip asset directories that do not exist', () => { - vi.mocked(existsSync).mockImplementation((target) => String(target).endsWith('nodes')); + it('should not watch asset directories that do not exist', () => { + mockDirs((target) => target.endsWith('nodes')); vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher); watchStaticFiles(vi.fn()); - expect(watch).toHaveBeenCalledTimes(1); - expect(vi.mocked(watch).mock.calls[0][0]).toBe(path.join(process.cwd(), 'nodes')); + const watched = vi.mocked(watch).mock.calls.map((call) => String(call[0])); + expect(watched).toContain(path.join(process.cwd(), 'nodes')); + expect(watched).not.toContain(path.join(process.cwd(), 'icons')); + }); + + it('should watch an asset directory created after startup', () => { + let exists = (target: string) => target.endsWith('nodes'); + mockDirs((target) => exists(target)); + vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher); + + const onChange = vi.fn(); + watchStaticFiles(onChange); + + // Only the root watcher can see a new asset root appear. + const rootCall = vi + .mocked(watch) + .mock.calls.find((call) => String(call[0]) === process.cwd()); + expect(rootCall).toBeDefined(); + + const rootListener = rootCall?.[1] as unknown as ( + event: string, + filename: string | null, + ) => void; + + exists = (target) => target.endsWith('nodes') || target.endsWith('icons'); + rootListener('rename', 'icons'); + + const watched = vi.mocked(watch).mock.calls.map((call) => String(call[0])); + expect(watched).toContain(path.join(process.cwd(), 'icons')); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should ignore a non-directory appearing with an asset directory name', () => { + mockDirs((target) => target.endsWith('nodes')); + vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher); + + watchStaticFiles(vi.fn()); + + const rootListener = vi + .mocked(watch) + .mock.calls.find((call) => String(call[0]) === process.cwd())?.[1] as unknown as ( + event: string, + filename: string | null, + ) => void; + + // `icons` exists as a file, so statSync never reports a directory. + expect(() => rootListener('rename', 'icons')).not.toThrow(); + const watched = vi.mocked(watch).mock.calls.map((call) => String(call[0])); + expect(watched).not.toContain(path.join(process.cwd(), 'icons')); }); it('should close every watcher on cleanup', () => { const watchers = [createFakeWatcher(), createFakeWatcher(), createFakeWatcher()]; let index = 0; - vi.mocked(existsSync).mockReturnValue(true); + mockDirs(() => true); vi.mocked(watch).mockImplementation(() => watchers[index++] as unknown as FSWatcher); watchStaticFiles(vi.fn())(); @@ -341,7 +395,7 @@ describe('dev utils', () => { it('should only react to static asset changes', () => { const onChange = vi.fn(); - vi.mocked(existsSync).mockImplementation((target) => String(target).endsWith('nodes')); + mockDirs((target) => target.endsWith('nodes')); vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher); watchStaticFiles(onChange); diff --git a/packages/@n8n/node-cli/src/commands/dev/utils.ts b/packages/@n8n/node-cli/src/commands/dev/utils.ts index 57e665e4ba4..2cc53fcea63 100644 --- a/packages/@n8n/node-cli/src/commands/dev/utils.ts +++ b/packages/@n8n/node-cli/src/commands/dev/utils.ts @@ -1,5 +1,5 @@ import { type ChildProcess, execSync, spawn, type SpawnOptions } from 'node:child_process'; -import { existsSync, watch } from 'node:fs'; +import { type FSWatcher, statSync, watch } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import picocolors from 'picocolors'; @@ -684,19 +684,44 @@ const STATIC_ASSET_DIRS = ['nodes', 'credentials', 'icons']; * without this an icon or schema edit never reaches `dist`. */ export function watchStaticFiles(onChange: () => void): () => void { - const watchers = STATIC_ASSET_DIRS.flatMap((dir) => { + const watchers: FSWatcher[] = []; + + const watchAssetDir = (dir: string): boolean => { const target = path.join(process.cwd(), dir); - if (!existsSync(target)) return []; + try { + if (!statSync(target).isDirectory()) return false; + } catch { + return false; + } // `filename` is relative to `target`, which the pattern already allows for. - return watch(target, { recursive: true }, (_event, filename) => { + const watcher = watch(target, { recursive: true }, (_event, filename) => { if (!filename) return; if (!STATIC_ASSET_PATTERN.test(filename)) return; onChange(); }); - }); + watcher.unref(); + watchers.push(watcher); + return true; + }; + + const missing = new Set(STATIC_ASSET_DIRS.filter((dir) => !watchAssetDir(dir))); + + // An asset root created after startup would otherwise stay unwatched until a + // restart. One non-recursive watch on the project root costs a single + // descriptor, where watching the tree would cost one per subdirectory. + if (missing.size > 0) { + const rootWatcher = watch(process.cwd(), (_event, filename) => { + if (!filename || !missing.has(filename)) return; + if (!watchAssetDir(filename)) return; + missing.delete(filename); + // The directory can already hold assets by the time the watcher attaches. + onChange(); + }); + rootWatcher.unref(); + watchers.push(rootWatcher); + } - for (const watcher of watchers) watcher.unref(); return () => { for (const watcher of watchers) watcher.close(); };