From dc94c88b7c10f1fe2996e3cbf44665ee6832e3a7 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 29 May 2026 13:40:17 +0200 Subject: [PATCH] refactor(project): Replace chokidar with @parcel/watcher Watching a large number of files with chokidar is not efficient, especially on Windows and slow file systems. Parcel's watcher is more efficient as it uses a native C++ Node module. Changing WatchHandler log level to "silly" to avoid duplicate log entries as BuildServer already logs the events at "verbose" level. --- package-lock.json | 439 +++++++++++++----- packages/project/lib/build/BuildServer.js | 6 +- .../project/lib/build/helpers/WatchHandler.js | 70 ++- packages/project/package.json | 2 +- .../test/lib/build/helpers/WatchHandler.js | 230 ++++++--- 5 files changed, 530 insertions(+), 217 deletions(-) diff --git a/package-lock.json b/package-lock.json index da05287a1e7..1e6b070997e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3346,6 +3346,319 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5049,31 +5362,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/append-transform": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", @@ -5526,18 +5814,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -6140,42 +6416,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -10297,18 +10537,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -12614,6 +12842,12 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12769,15 +13003,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-bundled": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", @@ -14807,30 +15032,6 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/readline-transform": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/readline-transform/-/readline-transform-1.0.0.tgz", @@ -18440,12 +18641,12 @@ "license": "Apache-2.0", "dependencies": { "@npmcli/config": "^10.4.0", + "@parcel/watcher": "^2.5.6", "@ui5/fs": "^5.0.0-alpha.4", "@ui5/logger": "^5.0.0-alpha.4", "ajv": "^8.18.0", "ajv-errors": "^3.0.0", "chalk": "^5.6.2", - "chokidar": "^3.6.0", "escape-string-regexp": "^5.0.0", "globby": "^14.1.0", "graceful-fs": "^4.2.11", diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index abe827dd41e..1200a177c19 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -115,8 +115,8 @@ class BuildServer extends EventEmitter { * * Awaiting watcher readiness avoids a race where source changes made immediately after * graph.serve() resolves would be missed. The race is most pronounced on - * Windows, where chokidar's ReadDirectoryChangesW backend has noticeably - * higher startup latency than inotify/FSEvents. + * Windows, where the ReadDirectoryChangesW backend has noticeably + * higher startup latency than FSEvents/inotify. * * @public * @param {@ui5/project/graph/ProjectGraph} graph Project graph containing all projects @@ -146,7 +146,7 @@ class BuildServer extends EventEmitter { }); watchHandler.on("change", (eventType, resourcePath, project) => { log.verbose(`Source change detected: ${eventType} ${resourcePath} in project '${project.getName()}'`); - this._projectResourceChanged(project, resourcePath, ["add", "unlink", "unlinkDir"].includes(eventType)); + this._projectResourceChanged(project, resourcePath, ["create", "delete"].includes(eventType)); }); await watchHandler.watch(this.#graph.getProjects()); } diff --git a/packages/project/lib/build/helpers/WatchHandler.js b/packages/project/lib/build/helpers/WatchHandler.js index 08d5d6d2ffd..7ec3eebb89e 100644 --- a/packages/project/lib/build/helpers/WatchHandler.js +++ b/packages/project/lib/build/helpers/WatchHandler.js @@ -1,5 +1,5 @@ import EventEmitter from "node:events"; -import chokidar from "chokidar"; +import parcelWatcher from "@parcel/watcher"; import {getLogger} from "@ui5/logger"; const log = getLogger("build:helpers:WatchHandler"); @@ -10,7 +10,7 @@ const log = getLogger("build:helpers:WatchHandler"); * @memberof @ui5/project/build/helpers */ class WatchHandler extends EventEmitter { - #closeCallbacks = []; + #subscriptions = []; constructor() { super(); @@ -25,51 +25,47 @@ class WatchHandler extends EventEmitter { } async _watchProject(project) { - let ready = false; const paths = project.getSourcePaths(); - log.verbose(`Watching source paths: ${paths.join(", ")}`); + log.verbose(`Watching source path(s): ${paths.join(", ")}`); - const watcher = chokidar.watch(paths, { - ignoreInitial: true, - }); - this.#closeCallbacks.push(async () => { - await watcher.close(); - }); - watcher.on("all", (event, filePath) => { - if (!ready) { - // Ignore events before ready - return; - } - if (event === "addDir") { - // Ignore directory creation events - return; - } - this.#handleWatchEvents(event, filePath, project).catch((err) => { - this.emit("error", err); + await Promise.all(paths.map(async (path) => { + const subscription = await parcelWatcher.subscribe(path, (err, events) => { + if (err) { + this.emit("error", err); + return; + } + for (const event of events) { + try { + this.#handleWatchEvents(event.type, event.path, project); + } catch (handlerErr) { + this.emit("error", handlerErr); + } + } }); - }); - const {promise, resolve} = Promise.withResolvers(); - - watcher.on("ready", () => { - ready = true; - resolve(); - }); - watcher.on("error", (err) => { - this.emit("error", err); - }); - - return promise; + this.#subscriptions.push(subscription); + })); } async destroy() { - for (const cb of this.#closeCallbacks) { - await cb(); + // Drain the subscriptions list so a second destroy() is a no-op and a partial + // failure cannot leave stale handles behind to be unsubscribed twice. + const subscriptions = this.#subscriptions; + this.#subscriptions = []; + // Run in parallel and collect failures so a single misbehaving subscription + // cannot leak the others. + const results = await Promise.allSettled(subscriptions.map((s) => s.unsubscribe())); + const failures = results.filter((r) => r.status === "rejected").map((r) => r.reason); + if (failures.length) { + const err = new AggregateError(failures, "Failed to unsubscribe one or more file watchers"); + this.emit("error", err); } } - async #handleWatchEvents(eventType, filePath, project) { + #handleWatchEvents(eventType, filePath, project) { const resourcePath = project.getVirtualPath(filePath); - log.verbose(`File changed: ${eventType} ${filePath} (as ${resourcePath} in project '${project.getName()}')`); + if (log.isLevelEnabled("silly")) { + log.silly(`FS event: ${eventType} ${filePath} (as ${resourcePath} in project '${project.getName()}')`); + } this.emit("change", eventType, resourcePath, project); } } diff --git a/packages/project/package.json b/packages/project/package.json index d6fb584b4d6..87c9e155502 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -58,12 +58,12 @@ }, "dependencies": { "@npmcli/config": "^10.4.0", + "@parcel/watcher": "^2.5.6", "@ui5/fs": "^5.0.0-alpha.4", "@ui5/logger": "^5.0.0-alpha.4", "ajv": "^8.18.0", "ajv-errors": "^3.0.0", "chalk": "^5.6.2", - "chokidar": "^3.6.0", "escape-string-regexp": "^5.0.0", "globby": "^14.1.0", "graceful-fs": "^4.2.11", diff --git a/packages/project/test/lib/build/helpers/WatchHandler.js b/packages/project/test/lib/build/helpers/WatchHandler.js index a7bbba1cbc6..a7c32ccebf1 100644 --- a/packages/project/test/lib/build/helpers/WatchHandler.js +++ b/packages/project/test/lib/build/helpers/WatchHandler.js @@ -3,14 +3,14 @@ import sinon from "sinon"; import esmock from "esmock"; let WatchHandler; -let chokidarWatchStub; +let subscribeStub; test.before(async () => { - chokidarWatchStub = sinon.stub(); + subscribeStub = sinon.stub(); WatchHandler = await esmock("../../../../lib/build/helpers/WatchHandler.js", { - "chokidar": { + "@parcel/watcher": { default: { - watch: chokidarWatchStub + subscribe: subscribeStub } } }); @@ -18,24 +18,30 @@ test.before(async () => { test.afterEach.always(() => { sinon.restore(); - chokidarWatchStub.reset(); + subscribeStub.reset(); }); -function createMockWatcher() { - const callbacks = {}; - const watcher = { - on: sinon.stub().callsFake((event, cb) => { - callbacks[event] = cb; - return watcher; - }), - close: sinon.stub().resolves() +function createMockSubscription() { + return { + unsubscribe: sinon.stub().resolves() }; - return {watcher, callbacks}; } -test.serial("watch: emits change events for file changes", async (t) => { - const {watcher, callbacks} = createMockWatcher(); - chokidarWatchStub.returns(watcher); +function captureCallback(subscription) { + let resolveCb; + const callbackReady = new Promise((resolve) => { + resolveCb = resolve; + }); + subscribeStub.callsFake(async (path, cb) => { + resolveCb(cb); + return subscription; + }); + return callbackReady; +} + +test.serial("watch: emits change events for file updates", async (t) => { + const subscription = createMockSubscription(); + const callbackReady = captureCallback(subscription); const handler = new WatchHandler(); const project = { @@ -44,28 +50,27 @@ test.serial("watch: emits change events for file changes", async (t) => { getName: () => "test-project" }; - const watchPromise = handler.watch([project]); - callbacks.ready(); - await watchPromise; + await handler.watch([project]); + const callback = await callbackReady; const changePromise = new Promise((resolve) => { handler.on("change", (eventType, resourcePath, proj) => { - t.is(eventType, "change"); + t.is(eventType, "update"); t.is(resourcePath, "/resources/file.js"); t.is(proj, project); resolve(); }); }); - callbacks.all("change", "/src/file.js"); + callback(null, [{type: "update", path: "/src/file.js"}]); await changePromise; await handler.destroy(); - t.true(watcher.close.calledOnce); + t.true(subscription.unsubscribe.calledOnce); }); -test.serial("watch: ignores events before ready", async (t) => { - const {watcher, callbacks} = createMockWatcher(); - chokidarWatchStub.returns(watcher); +test.serial("watch: emits create events for new files", async (t) => { + const subscription = createMockSubscription(); + const callbackReady = captureCallback(subscription); const handler = new WatchHandler(); const project = { @@ -74,21 +79,25 @@ test.serial("watch: ignores events before ready", async (t) => { getName: () => "test-project" }; - const changeSpy = sinon.spy(); - handler.on("change", changeSpy); + await handler.watch([project]); + const callback = await callbackReady; - const watchPromise = handler.watch([project]); - callbacks.all("change", "/src/file.js"); - callbacks.ready(); - await watchPromise; + const changePromise = new Promise((resolve) => { + handler.on("change", (eventType, resourcePath) => { + t.is(eventType, "create"); + t.is(resourcePath, "/resources/file.js"); + resolve(); + }); + }); + callback(null, [{type: "create", path: "/src/file.js"}]); + await changePromise; - t.is(changeSpy.callCount, 0, "No change emitted before ready"); await handler.destroy(); }); -test.serial("watch: ignores addDir events", async (t) => { - const {watcher, callbacks} = createMockWatcher(); - chokidarWatchStub.returns(watcher); +test.serial("watch: emits delete events", async (t) => { + const subscription = createMockSubscription(); + const callbackReady = captureCallback(subscription); const handler = new WatchHandler(); const project = { @@ -97,22 +106,25 @@ test.serial("watch: ignores addDir events", async (t) => { getName: () => "test-project" }; - const changeSpy = sinon.spy(); - handler.on("change", changeSpy); + await handler.watch([project]); + const callback = await callbackReady; - const watchPromise = handler.watch([project]); - callbacks.ready(); - await watchPromise; + const changePromise = new Promise((resolve) => { + handler.on("change", (eventType, resourcePath) => { + t.is(eventType, "delete"); + t.is(resourcePath, "/resources/gone.js"); + resolve(); + }); + }); + callback(null, [{type: "delete", path: "/src/gone.js"}]); + await changePromise; - callbacks.all("addDir", "/src/newdir"); - await new Promise((resolve) => setTimeout(resolve, 10)); - t.is(changeSpy.callCount, 0, "No change emitted for addDir"); await handler.destroy(); }); -test.serial("watch: emits error from watcher error event", async (t) => { - const {watcher, callbacks} = createMockWatcher(); - chokidarWatchStub.returns(watcher); +test.serial("watch: emits error from watcher callback error", async (t) => { + const subscription = createMockSubscription(); + const callbackReady = captureCallback(subscription); const handler = new WatchHandler(); const project = { @@ -120,9 +132,8 @@ test.serial("watch: emits error from watcher error event", async (t) => { getName: () => "test-project" }; - const watchPromise = handler.watch([project]); - callbacks.ready(); - await watchPromise; + await handler.watch([project]); + const callback = await callbackReady; const errorPromise = new Promise((resolve) => { handler.on("error", (err) => { @@ -130,14 +141,14 @@ test.serial("watch: emits error from watcher error event", async (t) => { resolve(); }); }); - callbacks.error(new Error("watcher error")); + callback(new Error("watcher error")); await errorPromise; await handler.destroy(); }); test.serial("watch: emits error when handler throws", async (t) => { - const {watcher, callbacks} = createMockWatcher(); - chokidarWatchStub.returns(watcher); + const subscription = createMockSubscription(); + const callbackReady = captureCallback(subscription); const handler = new WatchHandler(); const project = { @@ -148,9 +159,8 @@ test.serial("watch: emits error when handler throws", async (t) => { getName: () => "test-project" }; - const watchPromise = handler.watch([project]); - callbacks.ready(); - await watchPromise; + await handler.watch([project]); + const callback = await callbackReady; const errorPromise = new Promise((resolve) => { handler.on("error", (err) => { @@ -158,7 +168,113 @@ test.serial("watch: emits error when handler throws", async (t) => { resolve(); }); }); - callbacks.all("change", "/src/file.js"); + callback(null, [{type: "update", path: "/src/file.js"}]); await errorPromise; await handler.destroy(); }); + +test.serial("watch: subscribes to each source path", async (t) => { + const subA = createMockSubscription(); + const subB = createMockSubscription(); + subscribeStub.onFirstCall().resolves(subA); + subscribeStub.onSecondCall().resolves(subB); + + const handler = new WatchHandler(); + const project = { + getSourcePaths: () => ["/src", "/test"], + getVirtualPath: (filePath) => filePath, + getName: () => "test-project" + }; + + await handler.watch([project]); + + t.is(subscribeStub.callCount, 2, "subscribed once per source path"); + t.is(subscribeStub.firstCall.args[0], "/src"); + t.is(subscribeStub.secondCall.args[0], "/test"); + + await handler.destroy(); + t.true(subA.unsubscribe.calledOnce); + t.true(subB.unsubscribe.calledOnce); +}); + +test.serial("destroy: unsubscribes subscriptions in parallel", async (t) => { + const subA = createMockSubscription(); + const subB = createMockSubscription(); + // Make subA's unsubscribe block until subB's has at least started. + // If destroy() runs sequentially, subB.unsubscribe is never called while + // subA is still pending and the test deadlocks (caught by AVA timeout). + let resolveA; + subA.unsubscribe = sinon.stub().returns(new Promise((resolve) => { + resolveA = resolve; + })); + subB.unsubscribe = sinon.stub().callsFake(async () => { + resolveA(); + }); + subscribeStub.onFirstCall().resolves(subA); + subscribeStub.onSecondCall().resolves(subB); + + const handler = new WatchHandler(); + const project = { + getSourcePaths: () => ["/src", "/test"], + getVirtualPath: (filePath) => filePath, + getName: () => "test-project" + }; + + await handler.watch([project]); + await handler.destroy(); + + t.true(subA.unsubscribe.calledOnce); + t.true(subB.unsubscribe.calledOnce); +}); + +test.serial("destroy: continues unsubscribing when one subscription rejects", async (t) => { + const subA = createMockSubscription(); + const subB = createMockSubscription(); + const subC = createMockSubscription(); + subA.unsubscribe = sinon.stub().rejects(new Error("unsub A failed")); + subB.unsubscribe = sinon.stub().resolves(); + subC.unsubscribe = sinon.stub().rejects(new Error("unsub C failed")); + subscribeStub.onCall(0).resolves(subA); + subscribeStub.onCall(1).resolves(subB); + subscribeStub.onCall(2).resolves(subC); + + const handler = new WatchHandler(); + const project = { + getSourcePaths: () => ["/a", "/b", "/c"], + getVirtualPath: (filePath) => filePath, + getName: () => "test-project" + }; + + const errorSpy = sinon.spy(); + handler.on("error", errorSpy); + + await handler.watch([project]); + await handler.destroy(); + + t.true(subA.unsubscribe.calledOnce, "subA was attempted"); + t.true(subB.unsubscribe.calledOnce, "subB still ran despite subA failing"); + t.true(subC.unsubscribe.calledOnce, "subC still ran despite subA failing"); + t.is(errorSpy.callCount, 1, "single aggregated error emitted"); + const aggErr = errorSpy.firstCall.args[0]; + t.true(aggErr instanceof AggregateError, "emits AggregateError"); + t.is(aggErr.errors.length, 2, "both failures collected"); + t.deepEqual(aggErr.errors.map((e) => e.message).sort(), ["unsub A failed", "unsub C failed"]); +}); + +test.serial("destroy: is idempotent", async (t) => { + const subscription = createMockSubscription(); + captureCallback(subscription); + + const handler = new WatchHandler(); + const project = { + getSourcePaths: () => ["/src"], + getVirtualPath: (filePath) => filePath, + getName: () => "test-project" + }; + + await handler.watch([project]); + await handler.destroy(); + await handler.destroy(); + + t.is(subscription.unsubscribe.callCount, 1, "unsubscribe not invoked on second destroy"); +});