Skip to content

Commit 67674c5

Browse files
committed
test(project): Mock @parcel/watcher in BuildServer integration tests
Replace the real file watcher with an in-process esmock of @parcel/watcher so tests can fire watcher events deterministically instead of waiting on OS-level event delivery. The previous setup relied on a real watcher and padded against its latency with setTimeout(500) after each fs.appendFile/writeFile. Beyond the flakiness, @parcel/watcher fails to subscribe inside sandboxed environments where FSEvents/inotify access is restricted, blocking the entire suite. The mock keeps WatchHandler and BuildServer paths under test (only parcelWatcher.subscribe is replaced) and exposes FixtureTester.fireWatcherEvent for synchronous event delivery. Per-source-line coverage is reduced as no 'create' event is fired anymore which then does not cover _projectResourceChanged with fileAddedOrRemoved=true. The 'create' event was only fired as a side-effect because the files under test are copied into a tmp directory right before the test runs. Explicit tests for the create and delete events should be added to cover this code path.
1 parent 8629e4d commit 67674c5

1 file changed

Lines changed: 100 additions & 23 deletions

File tree

packages/project/test/lib/build/BuildServer.integration.js

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,93 @@
11
import test from "ava";
22
import sinonGlobal from "sinon";
3+
import esmock from "esmock";
34
import {fileURLToPath} from "node:url";
45
import {setTimeout} from "node:timers/promises";
56
import fs from "node:fs/promises";
67
import {appendFileSync} from "node:fs";
7-
import {graphFromPackageDependencies} from "../../../lib/graph/graph.js";
8+
import path from "node:path";
89
import {setLogLevel} from "@ui5/logger";
910
import Cache from "../../../lib/build/cache/Cache.js";
1011

1112
// Ensures that all logging code paths are tested
1213
setLogLevel("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+
1491
test.beforeEach((t) => {
1592
const sinon = t.context.sinon = sinonGlobal.createSandbox();
1693

@@ -29,6 +106,7 @@ test.beforeEach((t) => {
29106

30107
test.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

Comments
 (0)