Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 225 additions & 24 deletions packages/project/test/lib/build/BuildServer.integration.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,93 @@
import test from "ava";
import sinonGlobal from "sinon";
import esmock from "esmock";
import {fileURLToPath} from "node:url";
import {setTimeout} from "node:timers/promises";
import fs from "node:fs/promises";
import {appendFileSync} from "node:fs";
import {graphFromPackageDependencies} from "../../../lib/graph/graph.js";
import path from "node:path";
import {setLogLevel} from "@ui5/logger";
import Cache from "../../../lib/build/cache/Cache.js";

// Ensures that all logging code paths are tested
setLogLevel("silly");

// Mock @parcel/watcher for the entire import tree reachable from graph.js so the build server's
// WatchHandler does not try to subscribe to real FSEvents/inotify/ReadDirectoryChangesW handles.
// Tests fire watcher events deterministically via FixtureTester#fireWatcherEvent instead of
// waiting for OS-level event delivery, which is both flaky (timing-dependent) and impossible
// inside sandboxed environments where FSEvents/inotify access is restricted.
const watcherMock = createParcelWatcherMock();
const {graphFromPackageDependencies} = await esmock.p("../../../lib/graph/graph.js", {}, {
"@parcel/watcher": {
default: watcherMock.api,
...watcherMock.api,
},
});

function createParcelWatcherMock() {
// One entry per active subscription. WatchHandler subscribes once per source path per project,
// so a single project can produce 1..N entries (e.g. Library has src + test paths).
// Subscription paths are stored normalized to native separators (path.normalize) so prefix
// matching works regardless of whether tests pass POSIX-style paths on Windows.
const subscriptions = [];

const api = {
async subscribe(subPath, callback) {
const subscription = {path: path.normalize(subPath), callback};
subscriptions.push(subscription);
return {
async unsubscribe() {
const idx = subscriptions.indexOf(subscription);
if (idx !== -1) {
subscriptions.splice(idx, 1);
}
},
};
},
};

// Find the subscription whose watched path is the longest path-segment prefix of `filePath`,
// i.e. the callback that the real watcher would have invoked for an event on that file.
// `filePath` is expected to already be in native form.
function findSubscription(filePath) {
let match = null;
for (const sub of subscriptions) {
const isPrefix = filePath === sub.path ||
filePath.startsWith(sub.path + path.sep);
if (isPrefix && (!match || sub.path.length > match.path.length)) {
match = sub;
}
}
return match;
}

async function fire(type, filePath) {
// Tests build paths via template strings ("${fixturePath}/foo/bar"), which produces
// POSIX-style separators on Windows. The real @parcel/watcher always emits native paths,
// and WatchHandler/getVirtualPath compare against fsPath.join() output, so normalize
// before both matching and dispatch.
const nativePath = path.normalize(filePath);
const sub = findSubscription(nativePath);
if (!sub) {
throw new Error(
`No watcher subscription registered for path '${nativePath}'. ` +
`Active subscriptions: ${subscriptions.map((s) => s.path).join(", ") || "(none)"}`);
}
sub.callback(null, [{type, path: nativePath}]);
// Yield to the microtask queue so the synchronous "change" handler in WatchHandler and
// the resulting BuildServer#_projectResourceChanged invalidation propagate before the
// next request enqueues a build.
await new Promise((resolve) => setImmediate(resolve));
}

function reset() {
subscriptions.length = 0;
}

return {api, fire, reset};
}

test.beforeEach((t) => {
const sinon = t.context.sinon = sinonGlobal.createSandbox();

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

test.afterEach.always(async (t) => {
await t.context.fixtureTester.teardown();
watcherMock.reset();
t.context.sinon.restore();

process.off("ui5.log", t.context.logEventStub);
Expand All @@ -49,21 +127,21 @@ test.serial("Serve application.a, initial file changes", async (t) => {
// Directly change a source file in application.a before requesting it
const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`;
await fs.appendFile(changedFilePath, `\ntest("initial change");\n`);
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// Request the changed resource immediately
const resourceRequestPromise = fixtureTester.requestResource({
resource: "/test.js"
});

await setTimeout(500);

// Directly change the source file again, which should abort the current build and trigger a new one
await fs.appendFile(changedFilePath, `\ntest("second change");\n`);
await fixtureTester.fireWatcherEvent("update", changedFilePath);
await fs.appendFile(changedFilePath, `\ntest("third change");\n`);
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// Wait for the resource to be served
await resourceRequestPromise;
await setTimeout(500);

const resource2 = await fixtureTester.requestResource({
resource: "/test.js"
Expand Down Expand Up @@ -101,8 +179,7 @@ test.serial("Serve application.a, request application resource", async (t) => {
// Change a source file in application.a
const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`;
await fs.appendFile(changedFilePath, `\ntest("line added");\n`);

await setTimeout(500); // Wait for the file watcher to detect and propagate the change
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// #3 request with cache and changes
const res = await fixtureTester.requestResource({
Expand All @@ -127,6 +204,125 @@ test.serial("Serve application.a, request application resource", async (t) => {
t.true(servedFileContent.includes(`test("line added");`), "Resource contains changed file content");
});

test.serial("Serve application.a, create and delete a source file", async (t) => {
const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a");

await fixtureTester.serveProject();

// Create a new source file in application.a *before* the first resource request
const createdFilePath = `${fixtureTester.fixturePath}/webapp/created.js`;
await fs.writeFile(createdFilePath, `test("created file");\n`);
await fixtureTester.fireWatcherEvent("create", createdFilePath);

// #1 first request — initial build picks up the just-created file
const createdRes = await fixtureTester.requestResource({
resource: "/created.js",
assertions: {
projects: {
"application.a": {}
}
}
});
const createdContent = await createdRes.getString();
t.true(createdContent.includes(`test("created file");`),
"Created resource contains the expected content");

// #2 request again with cache — no rebuild expected
await fixtureTester.requestResource({
resource: "/created.js",
assertions: {
projects: {}
}
});

// Create a *second* new file after the first build has populated the persistent cache
const anotherFilePath = `${fixtureTester.fixturePath}/webapp/another.js`;
await fs.writeFile(anotherFilePath, `test("another file");\n`);
await fixtureTester.fireWatcherEvent("create", anotherFilePath);

// #3 request the second created resource — rebuild reuses cached task results
const anotherRes = await fixtureTester.requestResource({
resource: "/another.js",
assertions: {
projects: {
"application.a": {
skippedTasks: [
"escapeNonAsciiCharacters",
"replaceCopyright",
"enhanceManifest",
"generateFlexChangesBundle",
]
}
}
}
});
const anotherContent = await anotherRes.getString();
t.true(anotherContent.includes(`test("another file");`),
"Second created resource contains the expected content");

// Delete the second file again
await fs.rm(anotherFilePath);
await fixtureTester.fireWatcherEvent("delete", anotherFilePath);

// #4 the originally created file is still served and the cache from builds #1 and #2 is reused
await fixtureTester.requestResource({
resource: "/created.js",
assertions: {
projects: {}
}
});

// #5 the second file is no longer served, but requesting it triggers a build of the dependencies
// because the file is not known anymore and might come from a different project.
// Note: This is special for applications, which are served at root level. For libraries, the server
// can determine whether a resources is inside a project namespace and only trigger a build for the affected
// project. The logic could be improved, especially like in this case where the requested resource is outside
// of /resources or /test-resources.
await fixtureTester.requestResource({
resource: "/another.js",
notFound: true,
assertions: {
projects: {
"library.d": {},
"library.a": {},
"library.b": {},
"library.c": {},
}
}
});

// Delete the first source file again
await fs.rm(createdFilePath);
await fixtureTester.fireWatcherEvent("delete", createdFilePath);

// #6 request the deleted resource — must no longer be served
// Partial rebuild is needed as there is no complete cache of the project without the file
await fixtureTester.requestResource({
resource: "/created.js",
notFound: true,
assertions: {
projects: {
"application.a": {
skippedTasks: [
"escapeNonAsciiCharacters",
"replaceCopyright",
"enhanceManifest",
"generateFlexChangesBundle",
]
}
}
}
});

// Sanity check: the original /test.js is still served from the rebuilt project
await fixtureTester.requestResource({
resource: "/test.js",
assertions: {
projects: {}
}
});
});

test.serial("Serve application.a, request library resource", async (t) => {
const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a");

Expand Down Expand Up @@ -158,8 +354,7 @@ test.serial("Serve application.a, request library resource", async (t) => {
`<documentation>Library A (updated #1)</documentation>`
)
);

await setTimeout(500); // Wait for the file watcher to detect and propagate the change
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// #3 request with cache and changes
const dotLibraryResource = await fixtureTester.requestResource({
Expand Down Expand Up @@ -236,8 +431,7 @@ test.serial("Serve library", async (t) => {
` */\n// Test 1`
)
);

await setTimeout(500); // Wait for the file watcher to detect and propagate the change
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// #3 request with cache and changes
const resourceContent1 = await fixtureTester.requestResource({
Expand Down Expand Up @@ -266,8 +460,7 @@ test.serial("Serve library", async (t) => {
// Restore original file content

await fs.writeFile(changedFilePath, originalContent);

await setTimeout(500); // Wait for the file watcher to detect and propagate the change
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// #4 request with cache (no changes)
const resourceContent2 = await fixtureTester.requestResource({
Expand Down Expand Up @@ -310,6 +503,7 @@ test.serial("Serve application.a, request application resource AND library resou
// Change a source file in application.a and library.a
const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`;
await fs.appendFile(changedFilePath, `\ntest("line added");\n`);
await fixtureTester.fireWatcherEvent("update", changedFilePath);
const changedFilePath2 = `${fixtureTester.fixturePath}/node_modules/collection/library.a/src/library/a/.library`;
await fs.writeFile(
changedFilePath2,
Expand All @@ -318,8 +512,7 @@ test.serial("Serve application.a, request application resource AND library resou
`<documentation>Library A (updated #1)</documentation>`
)
);

await setTimeout(500); // Wait for the file watcher to detect and propagate the changes
await fixtureTester.fireWatcherEvent("update", changedFilePath2);

// #3 request with cache and changes
const [resource1, resource2] = await fixtureTester.requestResources({
Expand Down Expand Up @@ -381,8 +574,7 @@ test.serial("Serve application.a with --cache=Default", async (t) => {
// Change a source file in application.a
const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`;
await fs.appendFile(changedFilePath, `\ntest("line added for cache test");\n`);

await setTimeout(500); // Wait for the file watcher to detect and propagate the changes
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// #3: Request with valid cache, source changes --> only affected tasks rebuild
await fixtureTester.requestResource({
Expand Down Expand Up @@ -433,8 +625,7 @@ test.serial("Serve application.a with --cache=Off", async (t) => {
// Change a source file in application.a
const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`;
await fs.appendFile(changedFilePath, `\ntest("line added for ReadOnly test");\n`);

await setTimeout(500); // Wait for the file watcher to detect and propagate the changes
await fixtureTester.fireWatcherEvent("update", changedFilePath);

await fixtureTester.requestResource({
resource: "/test.js",
Expand Down Expand Up @@ -517,8 +708,7 @@ test.serial("Serve application.a with --cache=ReadOnly", async (t) => {
// Change a source file in application.a
const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`;
await fs.appendFile(changedFilePath, `\ntest("line added for ReadOnly test");\n`);

await setTimeout(500); // Wait for the file watcher to detect and propagate the changes
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// #3: Request with cache=ReadOnly --> affected tasks rebuild, BUT cache not updated
await fixtureTester.requestResource({
Expand Down Expand Up @@ -595,8 +785,7 @@ test.serial("Serve application.a with --cache=Force (1)", async (t) => {
// Change a source file in application.a
const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`;
await fs.appendFile(changedFilePath, `\ntest("line added for Force test");\n`);

await setTimeout(500); // Wait for the file watcher to detect and propagate the changes
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// #3: Request with cache=Force --> ERROR (cache invalid due to source changes)
const error = await t.throwsAsync(async () => {
Expand Down Expand Up @@ -796,7 +985,7 @@ test.serial("Source change during second build retries cleanly without no_cache
// path here: a real modification needs to flow through _projectResourceChanged so the
// project transitions to INVALIDATED and the next request enqueues a rebuild.
await fs.writeFile(changedFilePath, originalContent + "\n// pre-build-2 change\n");
await setTimeout(500); // let the watcher fire and settle
await fixtureTester.fireWatcherEvent("update", changedFilePath);

// Now suppress further watcher-driven aborts. The mid-build modification below is meant
// to flow through #revalidateSourceIndex inside allTasksCompleted, *not* through the
Expand Down Expand Up @@ -903,9 +1092,14 @@ class FixtureTester {
this._reader = this.buildServer.getReader();
}

async requestResource({resource, assertions}) {
async requestResource({resource, notFound = false, assertions}) {
this._sinon.resetHistory();
const res = await this._reader.byPath(resource);
if (notFound) {
this._t.is(res, null, `Resource '${resource}' must not be served`);
} else {
this._t.truthy(res, `Resource '${resource}' must be served`);
}
// Apply assertions if provided
if (assertions) {
this._assertBuild(assertions);
Expand All @@ -923,6 +1117,13 @@ class FixtureTester {
return returnedResources;
}

// Fires a synthetic watcher event through the in-process @parcel/watcher mock. Replaces the
// real-world cycle of "modify file on disk -> wait for the OS to surface the FS event" with
// a deterministic in-process call so tests can drive change notifications precisely.
async fireWatcherEvent(type, filePath) {
await watcherMock.fire(type, filePath);
}

_assertBuild(assertions) {
const {projects = {}} = assertions;

Expand Down
Loading