11import test from "ava" ;
22import sinonGlobal from "sinon" ;
3+ import esmock from "esmock" ;
34import { fileURLToPath } from "node:url" ;
45import { setTimeout } from "node:timers/promises" ;
56import fs from "node:fs/promises" ;
67import { appendFileSync } from "node:fs" ;
7- import { graphFromPackageDependencies } from "../../../lib/graph/graph.js " ;
8+ import path from "node:path " ;
89import { setLogLevel } from "@ui5/logger" ;
910import Cache from "../../../lib/build/cache/Cache.js" ;
1011
1112// Ensures that all logging code paths are tested
1213setLogLevel ( "silly" ) ;
1314
15+ // Mock @parcel /watcher for the entire import tree reachable from graph.js so the build server's
16+ // WatchHandler does not try to subscribe to real FSEvents/inotify/ReadDirectoryChangesW handles.
17+ // Tests fire watcher events deterministically via FixtureTester#fireWatcherEvent instead of
18+ // waiting for OS-level event delivery, which is both flaky (timing-dependent) and impossible
19+ // inside sandboxed environments where FSEvents/inotify access is restricted.
20+ const watcherMock = createParcelWatcherMock ( ) ;
21+ const { graphFromPackageDependencies} = await esmock . p ( "../../../lib/graph/graph.js" , { } , {
22+ "@parcel/watcher" : {
23+ default : watcherMock . api ,
24+ ...watcherMock . api ,
25+ } ,
26+ } ) ;
27+
28+ function createParcelWatcherMock ( ) {
29+ // One entry per active subscription. WatchHandler subscribes once per source path per project,
30+ // so a single project can produce 1..N entries (e.g. Library has src + test paths).
31+ // Subscription paths are stored normalized to native separators (path.normalize) so prefix
32+ // matching works regardless of whether tests pass POSIX-style paths on Windows.
33+ const subscriptions = [ ] ;
34+
35+ const api = {
36+ async subscribe ( subPath , callback ) {
37+ const subscription = { path : path . normalize ( subPath ) , callback} ;
38+ subscriptions . push ( subscription ) ;
39+ return {
40+ async unsubscribe ( ) {
41+ const idx = subscriptions . indexOf ( subscription ) ;
42+ if ( idx !== - 1 ) {
43+ subscriptions . splice ( idx , 1 ) ;
44+ }
45+ } ,
46+ } ;
47+ } ,
48+ } ;
49+
50+ // Find the subscription whose watched path is the longest path-segment prefix of `filePath`,
51+ // i.e. the callback that the real watcher would have invoked for an event on that file.
52+ // `filePath` is expected to already be in native form.
53+ function findSubscription ( filePath ) {
54+ let match = null ;
55+ for ( const sub of subscriptions ) {
56+ const isPrefix = filePath === sub . path ||
57+ filePath . startsWith ( sub . path + path . sep ) ;
58+ if ( isPrefix && ( ! match || sub . path . length > match . path . length ) ) {
59+ match = sub ;
60+ }
61+ }
62+ return match ;
63+ }
64+
65+ async function fire ( type , filePath ) {
66+ // Tests build paths via template strings ("${fixturePath}/foo/bar"), which produces
67+ // POSIX-style separators on Windows. The real @parcel/watcher always emits native paths,
68+ // and WatchHandler/getVirtualPath compare against fsPath.join() output, so normalize
69+ // before both matching and dispatch.
70+ const nativePath = path . normalize ( filePath ) ;
71+ const sub = findSubscription ( nativePath ) ;
72+ if ( ! sub ) {
73+ throw new Error (
74+ `No watcher subscription registered for path '${ nativePath } '. ` +
75+ `Active subscriptions: ${ subscriptions . map ( ( s ) => s . path ) . join ( ", " ) || "(none)" } ` ) ;
76+ }
77+ sub . callback ( null , [ { type, path : nativePath } ] ) ;
78+ // Yield to the microtask queue so the synchronous "change" handler in WatchHandler and
79+ // the resulting BuildServer#_projectResourceChanged invalidation propagate before the
80+ // next request enqueues a build.
81+ await new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
82+ }
83+
84+ function reset ( ) {
85+ subscriptions . length = 0 ;
86+ }
87+
88+ return { api, fire, reset} ;
89+ }
90+
1491test . beforeEach ( ( t ) => {
1592 const sinon = t . context . sinon = sinonGlobal . createSandbox ( ) ;
1693
@@ -29,6 +106,7 @@ test.beforeEach((t) => {
29106
30107test . afterEach . always ( async ( t ) => {
31108 await t . context . fixtureTester . teardown ( ) ;
109+ watcherMock . reset ( ) ;
32110 t . context . sinon . restore ( ) ;
33111
34112 process . off ( "ui5.log" , t . context . logEventStub ) ;
@@ -49,21 +127,21 @@ test.serial("Serve application.a, initial file changes", async (t) => {
49127 // Directly change a source file in application.a before requesting it
50128 const changedFilePath = `${ fixtureTester . fixturePath } /webapp/test.js` ;
51129 await fs . appendFile ( changedFilePath , `\ntest("initial change");\n` ) ;
130+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
52131
53132 // Request the changed resource immediately
54133 const resourceRequestPromise = fixtureTester . requestResource ( {
55134 resource : "/test.js"
56135 } ) ;
57136
58- await setTimeout ( 500 ) ;
59-
60137 // Directly change the source file again, which should abort the current build and trigger a new one
61138 await fs . appendFile ( changedFilePath , `\ntest("second change");\n` ) ;
139+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
62140 await fs . appendFile ( changedFilePath , `\ntest("third change");\n` ) ;
141+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
63142
64143 // Wait for the resource to be served
65144 await resourceRequestPromise ;
66- await setTimeout ( 500 ) ;
67145
68146 const resource2 = await fixtureTester . requestResource ( {
69147 resource : "/test.js"
@@ -101,8 +179,7 @@ test.serial("Serve application.a, request application resource", async (t) => {
101179 // Change a source file in application.a
102180 const changedFilePath = `${ fixtureTester . fixturePath } /webapp/test.js` ;
103181 await fs . appendFile ( changedFilePath , `\ntest("line added");\n` ) ;
104-
105- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the change
182+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
106183
107184 // #3 request with cache and changes
108185 const res = await fixtureTester . requestResource ( {
@@ -158,8 +235,7 @@ test.serial("Serve application.a, request library resource", async (t) => {
158235 `<documentation>Library A (updated #1)</documentation>`
159236 )
160237 ) ;
161-
162- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the change
238+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
163239
164240 // #3 request with cache and changes
165241 const dotLibraryResource = await fixtureTester . requestResource ( {
@@ -236,8 +312,7 @@ test.serial("Serve library", async (t) => {
236312 ` */\n// Test 1`
237313 )
238314 ) ;
239-
240- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the change
315+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
241316
242317 // #3 request with cache and changes
243318 const resourceContent1 = await fixtureTester . requestResource ( {
@@ -266,8 +341,7 @@ test.serial("Serve library", async (t) => {
266341 // Restore original file content
267342
268343 await fs . writeFile ( changedFilePath , originalContent ) ;
269-
270- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the change
344+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
271345
272346 // #4 request with cache (no changes)
273347 const resourceContent2 = await fixtureTester . requestResource ( {
@@ -310,6 +384,7 @@ test.serial("Serve application.a, request application resource AND library resou
310384 // Change a source file in application.a and library.a
311385 const changedFilePath = `${ fixtureTester . fixturePath } /webapp/test.js` ;
312386 await fs . appendFile ( changedFilePath , `\ntest("line added");\n` ) ;
387+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
313388 const changedFilePath2 = `${ fixtureTester . fixturePath } /node_modules/collection/library.a/src/library/a/.library` ;
314389 await fs . writeFile (
315390 changedFilePath2 ,
@@ -318,8 +393,7 @@ test.serial("Serve application.a, request application resource AND library resou
318393 `<documentation>Library A (updated #1)</documentation>`
319394 )
320395 ) ;
321-
322- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the changes
396+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath2 ) ;
323397
324398 // #3 request with cache and changes
325399 const [ resource1 , resource2 ] = await fixtureTester . requestResources ( {
@@ -381,8 +455,7 @@ test.serial("Serve application.a with --cache=Default", async (t) => {
381455 // Change a source file in application.a
382456 const changedFilePath = `${ fixtureTester . fixturePath } /webapp/test.js` ;
383457 await fs . appendFile ( changedFilePath , `\ntest("line added for cache test");\n` ) ;
384-
385- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the changes
458+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
386459
387460 // #3: Request with valid cache, source changes --> only affected tasks rebuild
388461 await fixtureTester . requestResource ( {
@@ -433,8 +506,7 @@ test.serial("Serve application.a with --cache=Off", async (t) => {
433506 // Change a source file in application.a
434507 const changedFilePath = `${ fixtureTester . fixturePath } /webapp/test.js` ;
435508 await fs . appendFile ( changedFilePath , `\ntest("line added for ReadOnly test");\n` ) ;
436-
437- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the changes
509+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
438510
439511 await fixtureTester . requestResource ( {
440512 resource : "/test.js" ,
@@ -517,8 +589,7 @@ test.serial("Serve application.a with --cache=ReadOnly", async (t) => {
517589 // Change a source file in application.a
518590 const changedFilePath = `${ fixtureTester . fixturePath } /webapp/test.js` ;
519591 await fs . appendFile ( changedFilePath , `\ntest("line added for ReadOnly test");\n` ) ;
520-
521- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the changes
592+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
522593
523594 // #3: Request with cache=ReadOnly --> affected tasks rebuild, BUT cache not updated
524595 await fixtureTester . requestResource ( {
@@ -595,8 +666,7 @@ test.serial("Serve application.a with --cache=Force (1)", async (t) => {
595666 // Change a source file in application.a
596667 const changedFilePath = `${ fixtureTester . fixturePath } /webapp/test.js` ;
597668 await fs . appendFile ( changedFilePath , `\ntest("line added for Force test");\n` ) ;
598-
599- await setTimeout ( 500 ) ; // Wait for the file watcher to detect and propagate the changes
669+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
600670
601671 // #3: Request with cache=Force --> ERROR (cache invalid due to source changes)
602672 const error = await t . throwsAsync ( async ( ) => {
@@ -796,7 +866,7 @@ test.serial("Source change during second build retries cleanly without no_cache
796866 // path here: a real modification needs to flow through _projectResourceChanged so the
797867 // project transitions to INVALIDATED and the next request enqueues a rebuild.
798868 await fs . writeFile ( changedFilePath , originalContent + "\n// pre-build-2 change\n" ) ;
799- await setTimeout ( 500 ) ; // let the watcher fire and settle
869+ await fixtureTester . fireWatcherEvent ( "update" , changedFilePath ) ;
800870
801871 // Now suppress further watcher-driven aborts. The mid-build modification below is meant
802872 // to flow through #revalidateSourceIndex inside allTasksCompleted, *not* through the
@@ -923,6 +993,13 @@ class FixtureTester {
923993 return returnedResources ;
924994 }
925995
996+ // Fires a synthetic watcher event through the in-process @parcel/watcher mock. Replaces the
997+ // real-world cycle of "modify file on disk -> wait for the OS to surface the FS event" with
998+ // a deterministic in-process call so tests can drive change notifications precisely.
999+ async fireWatcherEvent ( type , filePath ) {
1000+ await watcherMock . fire ( type , filePath ) ;
1001+ }
1002+
9261003 _assertBuild ( assertions ) {
9271004 const { projects = { } } = assertions ;
9281005
0 commit comments