diff --git a/internal/documentation/docs/pages/Builder.md b/internal/documentation/docs/pages/Builder.md index d36be96ec98..176357cc5ed 100644 --- a/internal/documentation/docs/pages/Builder.md +++ b/internal/documentation/docs/pages/Builder.md @@ -44,7 +44,7 @@ All available standard tasks are documented [in the API reference](https://ui5.g | generateBundle | *disabled* 4 | *disabled* 4 | *disabled* 4 | | | buildThemes | | | enabled | enabled | | generateThemeDesignerResources | | | *disabled* 5 | *disabled* 5 | -| generateVersionInfo | *disabled* 1 | | | | +| generateVersionInfo | enabled 6 | | | | | generateCachebusterInfo | *disabled* | *disabled* | | | | generateApiIndex | *disabled* 1 | | | | | generateResourcesJson | *disabled* | *disabled* | *disabled* | *disabled* | @@ -57,7 +57,8 @@ All available standard tasks are documented [in the API reference](https://ui5.g 2 Enabled for projects defining a [component preload configuration](./Configuration.md#component-preload-generation) 3 Enabled in `self-contained` build, which disables `generateComponentPreload` and `generateLibraryPreload` 4 Enabled for projects defining a [bundle configuration](./Configuration.md#custom-bundling) -5 Can be enabled for framework projects via the `includeTask` option. For other projects, this task is skipped +5 Can be enabled for framework projects via the `includeTask` option. For other projects, this task is skipped +6 Disabled for the server due to a corresponding middleware producing the same output ### minify diff --git a/internal/documentation/docs/pages/Server.md b/internal/documentation/docs/pages/Server.md index 64d6ae46155..1239753b885 100644 --- a/internal/documentation/docs/pages/Server.md +++ b/internal/documentation/docs/pages/Server.md @@ -146,6 +146,10 @@ In case a directory has been requested, this middleware renders an HTML with a l ## Standard Tasks As with the UI5 Builder, a set of standard tasks is being executed during a server build. Individual tasks can be included or excluded using the respective CLI options `--include-task` and `--exclude-task`. +::: info Exception +For the server, task [`generateVersionInfo`](../api/module-@ui5_builder_tasks_generateVersionInfo.md) is not executed because the corresponding middleware [`versionInfo`](#versioninfo) creates the same output file `sap-ui-version.json`. +::: + ::: info See [Builder Standard Tasks](./Builder.md#standard-tasks) for more explanation about each task. ::: diff --git a/internal/documentation/docs/updates/migrate-v5.md b/internal/documentation/docs/updates/migrate-v5.md index c9db7b58dac..5609d3d4345 100644 --- a/internal/documentation/docs/updates/migrate-v5.md +++ b/internal/documentation/docs/updates/migrate-v5.md @@ -320,6 +320,16 @@ Update your `ui5.yaml` configuration to reference an existing middleware instead | `serveThemes` | CSS files pre-built by `buildThemes` task and served via `serveResources` | `serveResources` | | `testRunner` | TestRunner resources served via `serveResources` from the UI5 framework | `serveResources` | +## `sap-ui-version.json` + +When running `ui5 build`, the standard task [`generateVersionInfo`](../api/module-@ui5_builder_tasks_generateVersionInfo) (producing a `sap-ui-version.json` file under `resources/`) is now **executed by default**. This applies to every build type (default, jsdoc, and self-contained) for projects of type `application`. For projects of other types (e.g. `library`), the behavior remains the same and [`generateVersionInfo`](../api/module-@ui5_builder_tasks_generateVersionInfo) is not executed. + +::: info Tip +To see which standard tasks are executed for each project type, check out the [Standard Tasks](../pages/Builder#standard-tasks) table in the UI5 Builder page. +::: + +When running `ui5 serve`, [`generateVersionInfo`](../api/module-@ui5_builder_tasks_generateVersionInfo) continues to **not be executed by default** because a corresponding middleware [`versionInfo`](../pages/Server.md#versioninfo) produces the same `sap-ui-version.json` output file. + ## Learn More - [Project: Type `component`](../pages/Project#component) diff --git a/packages/builder/test/expected/build/application.m/dest/resources/sap-ui-version.json b/packages/builder/test/expected/build/application.m/dest/resources/sap-ui-version.json new file mode 100644 index 00000000000..a109feea58d --- /dev/null +++ b/packages/builder/test/expected/build/application.m/dest/resources/sap-ui-version.json @@ -0,0 +1,7 @@ +{ + "name": "application.m", + "version": "1.0.0", + "buildTimestamp": "WILL_BE_IGNORED_BY_TESTS", + "scmRevision": "", + "libraries": [] +} diff --git a/packages/builder/test/expected/build/application.o/dest/resources/sap-ui-version.json b/packages/builder/test/expected/build/application.o/dest/resources/sap-ui-version.json new file mode 100644 index 00000000000..b6212fb25e3 --- /dev/null +++ b/packages/builder/test/expected/build/application.o/dest/resources/sap-ui-version.json @@ -0,0 +1,7 @@ +{ + "name": "application.o", + "version": "1.0.0", + "buildTimestamp": "WILL_BE_IGNORED_BY_TESTS", + "scmRevision": "", + "libraries": [] +} diff --git a/packages/builder/test/lib/builder/builder.js b/packages/builder/test/lib/builder/builder.js index cf0f0c0c46b..8772cbda0a6 100644 --- a/packages/builder/test/lib/builder/builder.js +++ b/packages/builder/test/lib/builder/builder.js @@ -71,14 +71,20 @@ async function checkFileContentsIgnoreLineFeeds(t, expectedFiles, expectedPath, currentContent = JSON.parse(currentContent.replace(/(:\s+)(\d+)/g, ": 0")); expectedContent = JSON.parse(expectedContent.replace(/(:\s+)(\d+)/g, ": 0")); t.deepEqual(currentContent, expectedContent); - } else { - if (expectedFile.endsWith(".json")) { - try { - t.deepEqual(JSON.parse(currentContent), JSON.parse(expectedContent), expectedFile); - } catch (e) { - t.falsy(e, expectedFile); + } else if (expectedFile.endsWith(".json")) { + try { + const currentJson = JSON.parse(currentContent); + const expectedJson = JSON.parse(expectedContent); + // Check if file is "sap-ui-version.json" and ignore the buildTimestamp property for comparison: + if (expectedFile.endsWith("sap-ui-version.json")) { + delete currentJson.buildTimestamp; + delete expectedJson.buildTimestamp; } + t.deepEqual(currentJson, expectedJson, expectedFile); + } catch (e) { + t.falsy(e, expectedFile); } + } else { t.is(currentContent.replace(newLineRegexp, "\n"), expectedContent.replace(newLineRegexp, "\n"), relativeFile); diff --git a/packages/project/lib/build/helpers/composeTaskList.js b/packages/project/lib/build/helpers/composeTaskList.js index 11a23d39dcb..dca89a180e3 100644 --- a/packages/project/lib/build/helpers/composeTaskList.js +++ b/packages/project/lib/build/helpers/composeTaskList.js @@ -25,7 +25,6 @@ export default function composeTaskList(allTasks, {selfContained, jsdoc, include selectedTasks.generateCachebusterInfo = false; selectedTasks.generateApiIndex = false; selectedTasks.generateThemeDesignerResources = false; - selectedTasks.generateVersionInfo = false; // Disable generateResourcesJson due to performance. // When executed it analyzes each module's AST and therefore @@ -45,7 +44,6 @@ export default function composeTaskList(allTasks, {selfContained, jsdoc, include selectedTasks.generateJsdoc = true; selectedTasks.executeJsdocSdkTransformation = true; selectedTasks.generateApiIndex = true; - selectedTasks.generateVersionInfo = true; // Include theme build as required for SDK selectedTasks.buildThemes = true; diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index a450d5a037c..8caa7a7f404 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -85,17 +85,7 @@ function createParcelWatcherMock() { subscriptions.length = 0; } - // Deliver an error to every active subscription callback, mirroring how @parcel/watcher - // surfaces a dropped-events condition ("File system must be re-scanned."). - async function fireError(err) { - // Snapshot: recovery destroys/recreates subscriptions while we iterate. - for (const sub of subscriptions.slice()) { - sub.callback(err); - } - await new Promise((resolve) => setImmediate(resolve)); - } - - return {api, fire, fireError, reset}; + return {api, fire, reset}; } test.beforeEach((t) => { @@ -164,46 +154,6 @@ test.serial("Serve application.a, initial file changes", async (t) => { t.true(servedFileContent.includes(`test("third change");`), "Resource contains third changed file content"); }); -// Complements the unit-level transient-failure coverage with a real-timer end-to-end pass: a -// burst of rapid watcher events arrives while a reader request is parked. The extra first-build -// (100 ms) and post-abort/transient (550 ms) settle windows must not hang the request, and the -// transient aborts within the burst must never surface a `serve-error` on the status feed — the -// server reports `serve-settling` while holding, then resolves on the single settled-tree rebuild. -test.serial("Serve application.a, rapid change burst reports settling and never errors", async (t) => { - const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); - - const statusEvents = []; - const statusHandler = (evt) => statusEvents.push(evt.status); - process.on("ui5.serve-status", statusHandler); - t.teardown(() => process.off("ui5.serve-status", statusHandler)); - - await fixtureTester.serveProject(); - - const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; - - // Park a request, then fire several rapid changes around it — the shape of an editor save-all - // or a `git checkout` landing while a build is in flight. - await fs.appendFile(changedFilePath, `\ntest("burst 1");\n`); - await fixtureTester.fireWatcherEvent("update", changedFilePath); - - const resourceRequestPromise = fixtureTester.requestResource({resource: "/test.js"}); - - await fs.appendFile(changedFilePath, `\ntest("burst 2");\n`); - await fixtureTester.fireWatcherEvent("update", changedFilePath); - await fs.appendFile(changedFilePath, `\ntest("burst 3");\n`); - await fixtureTester.fireWatcherEvent("update", changedFilePath); - - await resourceRequestPromise; - - const resource = await fixtureTester.requestResource({resource: "/test.js"}); - const servedFileContent = await resource.getString(); - t.true(servedFileContent.includes(`test("burst 1");`), "Resource reflects the first burst change"); - t.true(servedFileContent.includes(`test("burst 3");`), "Resource reflects the final burst change"); - - t.false(statusEvents.includes("serve-error"), - `No serve-error surfaced during the transient burst; got: ${statusEvents.join(", ")}`); -}); - test.serial("Serve application.a, request application resource", async (t) => { const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); @@ -213,6 +163,10 @@ test.serial("Serve application.a, request application resource", async (t) => { resource: "/test.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -243,6 +197,7 @@ test.serial("Serve application.a, request application resource", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo" ] } } @@ -254,46 +209,6 @@ test.serial("Serve application.a, request application resource", async (t) => { t.true(servedFileContent.includes(`test("line added");`), "Resource contains changed file content"); }); -// The incremental cache learns "what changed" only from watcher events. When @parcel/watcher -// reports that events were dropped, a source change may go unreported — a naive rebuild would -// then serve a stale cache hit. The recovery path forces a full re-scan so the un-notified -// change is still picked up. -test.serial("Serve application.a, dropped watcher events force a full re-scan", async (t) => { - const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); - - await fixtureTester.serveProject(); - - // #1 build and cache the resource. - const before = await fixtureTester.requestResource({resource: "/test.js"}); - t.false((await before.getString()).includes(`test("dropped-event change");`), - "baseline content does not yet contain the change"); - - // #2 confirm the cache is warm — a repeated request rebuilds nothing. - await fixtureTester.requestResource({ - resource: "/test.js", - assertions: {projects: {}}, - }); - - // Modify a source file WITHOUT firing a watcher change event: this models the OS dropping - // the FS event. Without recovery, the cache would keep serving the stale build result. - const changedFilePath = `${fixtureTester.fixturePath}/webapp/test.js`; - await fs.appendFile(changedFilePath, `\ntest("dropped-event change");\n`); - - // The watcher reports the drop instead of the change. Recovery runs asynchronously and - // emits `sourcesChanged` on completion; await that so the forced re-scan + invalidation - // have settled before the next request. - const recovered = new Promise((resolve) => fixtureTester.buildServer.once("sourcesChanged", resolve)); - await fixtureTester.fireWatcherError( - new Error("Events were dropped by the FSEvents client. File system must be re-scanned.")); - await recovered; - - // #3 the next request must reflect the un-notified change, proving the forced re-scan - // re-indexed the source tree and invalidated the stale cache. - const after = await fixtureTester.requestResource({resource: "/test.js"}); - t.true((await after.getString()).includes(`test("dropped-event change");`), - "resource reflects the change the watcher never reported, after the forced re-scan"); -}); - test.serial("Serve application.a, create and delete a source file", async (t) => { const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); @@ -309,6 +224,10 @@ test.serial("Serve application.a, create and delete a source file", async (t) => resource: "/created.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -341,6 +260,7 @@ test.serial("Serve application.a, create and delete a source file", async (t) => "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo" ] } } @@ -362,22 +282,13 @@ test.serial("Serve application.a, create and delete a source file", async (t) => } }); - // #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. + // #5 the second file is no longer served, thus requesting it shouldn't trigger a rebuild + // (all projects are still cached from the previous builds) await fixtureTester.requestResource({ resource: "/another.js", notFound: true, assertions: { - projects: { - "library.d": {}, - "library.a": {}, - "library.b": {}, - "library.c": {}, - } + projects: {} } }); @@ -398,6 +309,7 @@ test.serial("Serve application.a, create and delete a source file", async (t) => "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo" ] } } @@ -576,7 +488,10 @@ test.serial("Serve application.a, request application resource AND library resou resources: ["/test.js", "/resources/library/a/.library"], assertions: { projects: { + "library.d": {}, "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -648,6 +563,10 @@ test.serial("Serve application.a with --cache=Default", async (t) => { resource: "/test.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -677,6 +596,7 @@ test.serial("Serve application.a with --cache=Default", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo" ] } } @@ -699,12 +619,16 @@ test.serial("Serve application.a with --cache=Off", async (t) => { resource: "/test.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } }); - // #2: Request with cache=Off (again) --> all tasks execute again (no cache reuse) + // #2: Request with cache=Off (again) --> nothing rebuilds (cache not written, but no changes) await fixtureTester.requestResource({ resource: "/test.js", assertions: { @@ -717,10 +641,15 @@ test.serial("Serve application.a with --cache=Off", async (t) => { await fs.appendFile(changedFilePath, `\ntest("line added for ReadOnly test");\n`); await fixtureTester.fireWatcherEvent("update", changedFilePath); + // #3: Request the source file --> all tasks execute (cache still not written, but changes detected) await fixtureTester.requestResource({ resource: "/test.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -736,17 +665,21 @@ test.serial("Serve application.a with --cache=Off", async (t) => { await fixtureTester.teardown(); await fixtureTester.serveProject({config: {cache: Cache.Default}}); - // #3: Request with cache=Default --> all tasks execute (no cache from previous Off mode) + // #4: Request with cache=Default --> all tasks execute (no cache from previous Off mode) await fixtureTester.requestResource({ resource: "/test.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } }); - // #4: Request with cache=Default (again) --> nothing rebuilds (cache now exists) + // #5: Request with cache=Default (again) --> nothing rebuilds (cache now exists) await fixtureTester.requestResource({ resource: "/test.js", assertions: { @@ -758,11 +691,15 @@ test.serial("Serve application.a with --cache=Off", async (t) => { await fixtureTester.teardown(); await fixtureTester.serveProject({config: {cache: Cache.Off}}); - // #5: Request with cache=Off --> all tasks execute (ignores existing cache) + // #6: Request with cache=Off --> all tasks execute (ignores existing cache) await fixtureTester.requestResource({ resource: "/test.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -778,6 +715,10 @@ test.serial("Serve application.a with --cache=ReadOnly", async (t) => { resource: "/test.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -811,6 +752,7 @@ test.serial("Serve application.a with --cache=ReadOnly", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo" ] } } @@ -827,7 +769,8 @@ test.serial("Serve application.a with --cache=ReadOnly", async (t) => { await fixtureTester.teardown(); await fixtureTester.serveProject({config: {cache: Cache.Default}}); - // #4: Request with cache=Default, no new changes --> rebuilds again (cache from #3 missing) + // #4: Request with cache=Default, no new changes --> cache from #3 missing + // --> only affected tasks get re-executed (cache reuse) // This validates that ReadOnly didn't write the cache in step #3 await fixtureTester.requestResource({ resource: "/test.js", @@ -839,6 +782,7 @@ test.serial("Serve application.a with --cache=ReadOnly", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo" ] } } @@ -855,6 +799,10 @@ test.serial("Serve application.a with --cache=Force (1)", async (t) => { resource: "/test.js", assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -912,8 +860,8 @@ test.serial("Serve application.a with --cache=Force (2)", async (t) => { // Regression: a non-abort build error used to leave #activeBuild set, deadlocking the BuildServer // so subsequent resource requests would hang forever. The fix in #processBuildRequests clears -// #activeBuild in a finally block and surfaces the error via ServeLogger instead of throwing — -// verify a second request still rejects (with the same root cause) instead of hanging. +// #activeBuild in a finally block and emits "error" instead of throwing — verify a second request +// still rejects (with the same root cause) instead of hanging. test.serial("Build server recovers from non-abort build error (no deadlock)", async (t) => { const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); @@ -939,86 +887,9 @@ test.serial("Build server recovers from non-abort build error (no deadlock)", as `Second request rejects with the same error instead of deadlocking. Got: ${secondError && secondError.message}` ); - // Build errors are surfaced via ServeLogger, not via the "error" event — the latter stays - // reserved for fatal failures (watcher crash, etc.) that must terminate the server. + // Each failed build emits exactly one "error" event await setTimeout(50); - t.is(errorEvents.length, 0, "No fatal 'error' events emitted for recoverable build failures"); -}); - -// A normal build error must gate further rebuilds of the same project: deterministic -// builds recover only when their input changes, so re-running the same build would just -// re-produce the same failure. The gate lifts on any source change that invalidates the -// errored project (directly or via a dependency), routed through _projectResourceChanged. -test.serial("Errored project is not rebuilt until input changes", async (t) => { - const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); - await fixtureTester.serveProject({config: {cache: Cache.Force}, expectBuildErrors: true}); - - // First request triggers a build that fails because cache=Force has no cache. - const firstError = await t.throwsAsync(() => fixtureTester.requestResource({resource: "/test.js"})); - - // Second and third requests must reject with the *same error instance* — the gate - // short-circuits with the captured error rather than re-running the build (which - // would produce a new Error instance carrying the same message). - const secondError = await t.throwsAsync(() => fixtureTester.requestResource({resource: "/test.js"})); - const thirdError = await t.throwsAsync(() => fixtureTester.requestResource({resource: "/test.js"})); - t.is(secondError, firstError, "Gate returns the captured error instance instead of rebuilding"); - t.is(thirdError, firstError, "Gate keeps returning the same error until input changes"); - - // Simulate a source change that invalidates the errored project. This should lift the - // gate; the next request attempts a rebuild (still fails since cache=Force has no cache, - // but with a *new* error instance — proving the gate released and the build ran). - fixtureTester.buildServer._projectResourceChanged( - fixtureTester.graph.getProject("application.a"), - "/resources/application/a/test.js", - false - ); - await setTimeout(50); - const afterChangeError = await t.throwsAsync(() => fixtureTester.requestResource({resource: "/test.js"})); - t.not(afterChangeError, firstError, "Source change lifted the gate; a fresh build ran"); - t.true(afterChangeError.message.includes(`Cache is in "Force" mode`), - "Fresh build produced an equivalent error"); -}); - -// A build task that throws (here: buildThemes on a LESS syntax error) leaves the project's -// reused in-memory state holding the failing task's partial output and the previous -// successful build's result signature. Without a failure-path reset, fixing the source and -// re-requesting keeps serving the broken stages: the recovered source signature matches the -// retained result signature, so #findResultCache short-circuits and never re-imports the -// (uncorrupted) cached stages. This is the theme-build "fails to recover" scenario. -test.serial("Failed theme build recovers after the source is fixed", async (t) => { - const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "theme.library.e"); - await fixtureTester.serveProject({expectBuildErrors: true}); - - const cssResource = "/resources/theme/library/e/themes/my_theme/library.css"; - const lessFilePath = - `${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/library.source.less`; - const originalLess = await fs.readFile(lessFilePath, {encoding: "utf8"}); - - // #1 initial request builds the theme successfully. - const initialCss = await fixtureTester.requestResource({resource: cssResource}); - t.true((await initialCss.getString()).includes("background-color"), - "Initial build produced valid CSS"); - - // Inject a LESS syntax error and notify the watcher. - await fs.writeFile(lessFilePath, `${originalLess}\n@@@ this is not valid less @@@\n`); - await fixtureTester.fireWatcherEvent("update", lessFilePath); - - // #2 request now fails: buildThemes throws on the malformed input. - const buildError = await t.throwsAsync(() => fixtureTester.requestResource({resource: cssResource})); - t.truthy(buildError, "Build fails while the LESS file has a syntax error"); - - // Fix the source and notify the watcher. - await fs.writeFile(lessFilePath, originalLess); - await fixtureTester.fireWatcherEvent("update", lessFilePath); - - // #3 request after the fix must recover and serve valid CSS — the failed build's partial - // state was discarded and clean stages were re-imported. - const recoveredCss = await fixtureTester.requestResource({resource: cssResource}); - const recoveredContent = await recoveredCss.getString(); - t.true(recoveredContent.includes("background-color"), - "Build recovers and serves valid CSS after the source is fixed"); - t.false(recoveredContent.includes("test{"), - "Recovered CSS does not contain artifacts of the broken build"); + t.is(errorEvents.length, 2, "Two build errors were emitted, one per failed build attempt"); }); // ProjectBuildCache's StageCache must be cleared correctly when a build is aborted. @@ -1194,6 +1065,76 @@ test.serial("Source change during second build retries cleanly without no_cache "Retry served content reflecting the mid-build-2 change"); }); +test.serial("Serve application.a (test exclusion of generateVersionInfo)", async (t) => { + // This test verifies that the "generateVersionInfo" task + // can be excluded from the server build via the "excludedTasks" config option. + + const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); + + // #1 Exclude "generateVersionInfo": + await fixtureTester.serveProject({ + config: { + excludedTasks: ["generateVersionInfo"], + } + }); + + // Request a resource to trigger the build: + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "application.a": { + executedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "replaceVersion", + "minify", + "generateFlexChangesBundle", + "enhanceManifest", + "generateComponentPreload" + // "generateVersionInfo" is NOT EXECUTED + ], + }, + } + }, + }); + + await fixtureTester.teardown(); + + + // #2 Don't exclude tasks (includes "generateVersionInfo"): + await fixtureTester.serveProject({ + config: { + excludedTasks: [], + } + }); + + // Request a resource to trigger the build: + await fixtureTester.requestResource({ + resource: "/test.js", + assertions: { + projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": { + executedTasks: [ + "escapeNonAsciiCharacters", + "replaceCopyright", + "replaceVersion", + "minify", + "generateFlexChangesBundle", + "enhanceManifest", + "generateComponentPreload", + "generateVersionInfo" // IS EXECUTED + ], + }, + } + }, + }); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } @@ -1291,13 +1232,6 @@ class FixtureTester { await watcherMock.fire(type, filePath); } - // Fires a dropped-events error through the in-process @parcel/watcher mock. Models the - // real-world "Events were dropped by the FSEvents client. File system must be - // re-scanned." fault: the incremental change signal is now known to be incomplete. - async fireWatcherError(err) { - await watcherMock.fireError(err); - } - _assertBuild(assertions) { const {projects = {}} = assertions; @@ -1333,6 +1267,18 @@ class FixtureTester { const expectedProjects = Object.keys(projects); this._t.deepEqual(projectsInOrder, expectedProjects); + // Optional check: Assert executed tasks + for (const [projectName, expected] of Object.entries(projects)) { + if (!expected.executedTasks) { + continue; // no executedTasks specified -> skip the check + } + const expectedExecuted = expected.executedTasks || []; + const actualExecuted = (tasksByProject[projectName]?.executed || []).sort(); + const expectedArray = expectedExecuted.sort(); + this._t.deepEqual(actualExecuted, expectedArray, + "All executed tasks of all projects should match expected"); + } + // Assert skipped tasks per project for (const [projectName, expectedSkipped] of Object.entries(projects)) { const skippedTasks = expectedSkipped.skippedTasks || []; diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index c754b7213f1..1cbd535860f 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -44,6 +44,13 @@ test.serial("Build application.a project multiple times", async (t) => { config: {destPath, cleanDest: false}, assertions: { projects: { + // Default builds of project type "application" include the "generateVersionInfo" + // task which requires dependencies to be built, so all dependencies are expected to be built here. + // Subsequent builds can reuse the cached results of these dependencies. + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -75,6 +82,7 @@ test.serial("Build application.a project multiple times", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo", ] } } @@ -90,12 +98,10 @@ test.serial("Build application.a project multiple times", async (t) => { await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { - projects: { - "library.d": {}, - "library.a": {}, - "library.b": {}, - "library.c": {}, - } + // Dependencies are NOT rebuilt because + // they were already built in build #1 and can be reused from cache. + // Thus, empty assertion for built projects. + projects: {} } }); @@ -180,6 +186,7 @@ test.serial("Build application.a project multiple times", async (t) => { "enhanceManifest", "escapeNonAsciiCharacters", "generateFlexChangesBundle", + "generateVersionInfo", "replaceCopyright" ] } @@ -204,6 +211,7 @@ test.serial("Build application.a project multiple times", async (t) => { "escapeNonAsciiCharacters", "generateFlexChangesBundle", "replaceCopyright", + "generateVersionInfo", ] }} } @@ -264,6 +272,7 @@ test.serial("Build application.a (with various dependencies)", async (t) => { "escapeNonAsciiCharacters", "generateFlexChangesBundle", "replaceCopyright", + "generateVersionInfo", ] } } @@ -308,6 +317,7 @@ test.serial("Build application.a (with various dependencies)", async (t) => { "escapeNonAsciiCharacters", "generateFlexChangesBundle", "replaceCopyright", + "generateVersionInfo", ] } } @@ -330,6 +340,7 @@ test.serial("Build application.a (with various dependencies)", async (t) => { "escapeNonAsciiCharacters", "generateFlexChangesBundle", "replaceCopyright", + "generateVersionInfo", ] } } @@ -342,18 +353,25 @@ test.serial("Build application.a (including only some dependencies)", async (t) const destPath = fixtureTester.destPath; // In this test, we're testing the "dependencyIncludes" build option - // which allows to include only a subset of the dependencies of a project in the build. + // which allows to include only a subset of the dependencies of a project in the build result. // "application.a" has 4 dependencies defined: library.a, library.b, library.c and library.d. // #1 build // Only include library.a and library.b as dependencies, but not library.c and library.d: + + // Note: For the initial build, ALL dependencies are built, + // due to the execution of the "generateVersionInfo" task in application.a which requires dependencies to be built. + // In subsequent builds, the cached results of the dependencies can be reused, + // so "includeDependency" can come to effect. await fixtureTester.buildProject({ config: {destPath, cleanDest: false, dependencyIncludes: {includeDependency: ["library.a", "library.b"]}}, assertions: { projects: { + "library.d": {}, "library.a": {}, "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -364,6 +382,10 @@ test.serial("Build application.a (including only some dependencies)", async (t) {encoding: "utf8"})); await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/b/library-preload.js`, {encoding: "utf8"})); + + // Check that the remaining dependencies are NOT in the destPath: + // (Note: although library.c and library.d were built, + // they are not included in the build result because of the flag) await t.throwsAsync(fs.readFile(`${destPath}/resources/library/c/library-preload.js`, {encoding: "utf8"})); await t.throwsAsync(fs.readFile(`${destPath}/resources/library/d/library-preload.js`, @@ -372,14 +394,12 @@ test.serial("Build application.a (including only some dependencies)", async (t) // #2 build // Exclude library.d as dependency, but include all other dependencies - // (builds of library.a and library.b can be reused from cache): + // (builds of library.a, library.b and library.c can be reused from cache): await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true, excludeDependency: ["library.d"]}}, assertions: { - projects: { - "library.c": {}, - } + projects: {} } }); @@ -390,20 +410,20 @@ test.serial("Build application.a (including only some dependencies)", async (t) {encoding: "utf8"})); await t.notThrowsAsync(fs.readFile(`${destPath}/resources/library/c/library-preload.js`, {encoding: "utf8"})); + + // Check that the excluded dependency is NOT in the destPath: await t.throwsAsync(fs.readFile(`${destPath}/resources/library/d/library-preload.js`, {encoding: "utf8"})); // #3 build - // Include all dependencies (only library.d is built) - // (builds of library.a, library.b, and library.c can be reused from cache): + // Include all dependencies + // (builds of ALL libraries can be reused from cache): await fixtureTester.buildProject({ config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, assertions: { - projects: { - "library.d": {}, - } + projects: {} } }); @@ -448,6 +468,10 @@ test.serial("Build application.a (custom task and tag handling)", async (t) => { config: {destPath, cleanDest: true}, assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -471,6 +495,7 @@ test.serial("Build application.a (custom task and tag handling)", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo", ] } } @@ -523,6 +548,10 @@ test.serial("Build application.a (multiple custom tasks)", async (t) => { config: {destPath, cleanDest: true}, assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -561,6 +590,7 @@ test.serial("Build application.a (multiple custom tasks)", async (t) => { "escapeNonAsciiCharacters", "generateFlexChangesBundle", "replaceCopyright", + "generateVersionInfo", ] } } @@ -582,6 +612,10 @@ test.serial("Build application.a (multiple custom tasks 2)", async (t) => { config: {destPath, cleanDest: true}, assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -606,6 +640,7 @@ test.serial("Build application.a (multiple custom tasks 2)", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo", ] } } @@ -635,6 +670,7 @@ test.serial("Build application.a (multiple custom tasks 2)", async (t) => { "escapeNonAsciiCharacters", "generateFlexChangesBundle", "replaceCopyright", + "generateVersionInfo", ] } } @@ -834,6 +870,7 @@ builder: "generateFlexChangesBundle", "replaceCopyright", "replaceVersion", + "generateVersionInfo", ] }, } @@ -985,6 +1022,10 @@ test.serial("Build application.a (Custom Component preload configuration)", asyn config: {destPath, cleanDest: true}, assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -1064,6 +1105,7 @@ test.serial("Build application.a (self-contained build)", async (t) => { "generateFlexChangesBundle", "replaceCopyright", "transformBootstrapHtml", + "generateVersionInfo", ] } } @@ -1181,6 +1223,7 @@ test.serial("Build application.a (Custom bundling)", async (t) => { "escapeNonAsciiCharacters", "generateFlexChangesBundle", "replaceCopyright", + "generateVersionInfo", ] } } @@ -1217,6 +1260,7 @@ test.serial("Build application.a (Custom bundling)", async (t) => { "escapeNonAsciiCharacters", "generateFlexChangesBundle", "replaceCopyright", + "generateVersionInfo", ] } } @@ -2465,6 +2509,10 @@ test.serial("Build application.a with --cache=Default", async (t) => { config: {destPath, cleanDest: false, cache: Cache.Default}, assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -2493,6 +2541,7 @@ test.serial("Build application.a with --cache=Default", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo", ] } } @@ -2514,7 +2563,11 @@ test.serial("Build application.a with --cache=Off", async (t) => { config: {destPath, cleanDest: false, cache: Cache.Off}, assertions: { projects: { - "application.a": {} + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {}, } } }); @@ -2524,7 +2577,11 @@ test.serial("Build application.a with --cache=Off", async (t) => { config: {destPath, cleanDest: true, cache: Cache.Off}, assertions: { projects: { - "application.a": {} + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {}, } } }); @@ -2534,7 +2591,11 @@ test.serial("Build application.a with --cache=Off", async (t) => { config: {destPath, cleanDest: true, cache: Cache.Default}, assertions: { projects: { - "application.a": {} + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {}, } } }); @@ -2552,7 +2613,11 @@ test.serial("Build application.a with --cache=Off", async (t) => { config: {destPath, cleanDest: true, cache: Cache.Off}, assertions: { projects: { - "application.a": {} + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {}, } } }); @@ -2567,6 +2632,10 @@ test.serial("Build application.a with --cache=ReadOnly", async (t) => { config: {destPath, cleanDest: false, cache: Cache.Default}, assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -2595,6 +2664,7 @@ test.serial("Build application.a with --cache=ReadOnly", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo", ] } } @@ -2618,6 +2688,7 @@ test.serial("Build application.a with --cache=ReadOnly", async (t) => { "replaceCopyright", "enhanceManifest", "generateFlexChangesBundle", + "generateVersionInfo", ] } } @@ -2634,6 +2705,10 @@ test.serial("Build application.a with --cache=Force (1)", async (t) => { config: {destPath, cleanDest: false, cache: Cache.Default}, assertions: { projects: { + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, "application.a": {} } } @@ -2680,6 +2755,51 @@ test.serial("Build application.a with --cache=Force (2)", async (t) => { `Use "Default", "ReadOnly" or "Off" to rebuild.`)); }); +test.serial("Build application.a with --exclude-task=generateVersionInfo", async (t) => { + // This test verifies that when task generateVersionInfo is excluded, + // the sap-ui-version.json file is NOT generated in the output + // AND the dependencies are not automatically built. + + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1 Build with empty cache and exclude generateVersionInfo task + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, excludedTasks: ["generateVersionInfo"]}, + assertions: { + projects: { + // Only application.a is built, dependencies are skipped + "application.a": {} + } + } + }); + + // Verify that sap-ui-version.json does NOT exist in the output + const versionInfoPath = `${destPath}/resources/sap-ui-version.json`; + await t.throwsAsync(fs.readFile(versionInfoPath, {encoding: "utf8"}), undefined, + "sap-ui-version.json should NOT exist when generateVersionInfo task is excluded"); + + + // #2 Build with empty cache and include all default tasks now + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false}, + assertions: { + projects: { + // All dependencies are built now + "library.d": {}, + "library.a": {}, + "library.b": {}, + "library.c": {}, + "application.a": {} + } + } + }); + + // Verify that sap-ui-version.json DOES exist in the output now + await t.notThrowsAsync(fs.readFile(versionInfoPath, {encoding: "utf8"}), undefined, + "sap-ui-version.json should exist when generateVersionInfo task is included"); +}); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index c4451c556c3..2dd78996b34 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -1295,6 +1295,7 @@ test("Custom task attached to a disabled task", async (t) => { "generateFlexChangesBundle", "generateComponentPreload", "myTask", + "generateVersionInfo", ], "Correct tasks execution"); }); @@ -1423,11 +1424,17 @@ test("getRequiredDependencies: Custom Task", async (t) => { }); test("getRequiredDependencies: Default application", async (t) => { + // This test includes a mock project of type "application". + // By default, builds of this project type always contain the "generateVersionInfo" task, + // which requires dependencies to be built. + const project = getMockProject("application"); project.getBundles = () => []; const taskRunner = createTaskRunner(t, project); - t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([]), - "Default application project does not require dependencies"); + t.deepEqual(await taskRunner.getRequiredDependencies(), new Set([ + "dep.a", + "dep.b", + ]), "Default application project DOES require dependencies"); }); test("getRequiredDependencies: Default component", async (t) => { diff --git a/packages/project/test/lib/build/helpers/composeTaskList.js b/packages/project/test/lib/build/helpers/composeTaskList.js index 910994aabec..f1fb5f905c4 100644 --- a/packages/project/test/lib/build/helpers/composeTaskList.js +++ b/packages/project/test/lib/build/helpers/composeTaskList.js @@ -55,6 +55,7 @@ const allTasks = [ "minify", "buildThemes", "generateLibraryManifest", + "generateVersionInfo", "generateFlexChangesBundle", "generateComponentPreload", "generateBundle", @@ -76,6 +77,7 @@ const allTasks = [ "minify", "buildThemes", "generateLibraryManifest", + "generateVersionInfo", "generateFlexChangesBundle", "generateComponentPreload", "generateBundle", @@ -98,9 +100,10 @@ const allTasks = [ "buildThemes", "transformBootstrapHtml", "generateLibraryManifest", + "generateVersionInfo", "generateFlexChangesBundle", "generateStandaloneAppBundle", - "generateBundle" + "generateBundle", ] ], [ @@ -134,6 +137,7 @@ const allTasks = [ "minify", "buildThemes", "generateLibraryManifest", + "generateVersionInfo", "generateFlexChangesBundle", "generateComponentPreload", "generateResourcesJson", @@ -195,6 +199,7 @@ const allTasks = [ "minify", "buildThemes", "generateLibraryManifest", + "generateVersionInfo", "generateFlexChangesBundle", "generateComponentPreload", "generateBundle", @@ -219,6 +224,7 @@ const allTasks = [ "minify", "buildThemes", "generateLibraryManifest", + "generateVersionInfo", "generateFlexChangesBundle", "generateComponentPreload", "generateBundle", diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 201b9f3a485..61104345bfa 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -4,11 +4,11 @@ import process from "node:process"; import express from "express"; import portscanner from "portscanner"; import MiddlewareManager from "./middleware/MiddlewareManager.js"; -import createErrorHandler from "./middleware/errorHandler.js"; import attachLiveReloadServer from "./liveReload/server.js"; import {createReaderCollection} from "@ui5/fs/resourceFactory"; import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; import {getLogger} from "@ui5/logger"; +import Cache from "@ui5/project/build/cache/Cache"; const log = getLogger("server"); /** @@ -151,7 +151,7 @@ async function _addSsl({app, key, cert}) { export async function serve(graph, { port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false, sendSAPTargetCSP = false, - simpleIndex = false, liveReload = false, serveCSPReports = false, cache = "Default", + simpleIndex = false, liveReload = false, serveCSPReports = false, cache = Cache.Default, ui5DataDir, includedTasks, excludedTasks, }, error) { const rootProject = graph.getRoot(); @@ -188,6 +188,16 @@ export async function serve(graph, { // Ensure sap.ui.core is always built initially (if present in the graph) initialBuildIncludedDependencies.push("sap.ui.core"); } + + // Explicitly exclude task "generateVersionInfo" for Server builds + // because middleware "versionInfo" will generate the version info anyways. + if (!Array.isArray(excludedTasks)) { + excludedTasks = []; + } + if (!excludedTasks.includes("generateVersionInfo")) { + excludedTasks.push("generateVersionInfo"); + } + const buildServer = await graph.serve({ initialBuildIncludedDependencies, includedTasks, @@ -220,11 +230,6 @@ export async function serve(graph, { Buffer.from(getRandomValues(new Uint8Array(9))).toString("base64url") : null; - const liveReloadOptions = { - active: liveReload, - token: webSocketToken - }; - const middlewareManager = new MiddlewareManager({ graph, rootProject, @@ -234,20 +239,15 @@ export async function serve(graph, { sendSAPTargetCSP, serveCSPReports, simpleIndex, - liveReload: liveReloadOptions, - // Consulted by the serveBuildError gate to divert HTML navigations while the - // build server is globally in ERROR. - getServeError: () => buildServer.getServeError() + liveReload: { + active: liveReload, + token: webSocketToken + } } }); let app = express(); await middlewareManager.applyMiddleware(app); - // Terminal error handler for the middleware chain. Registered after applyMiddleware - // so it sits last and intercepts every next(err) — including those from custom - // middleware where we can't wrap a try/catch. Threads the live-reload config in - // so the HTML error page can embed the live-reload client script. - app.use(createErrorHandler({liveReload: liveReloadOptions})); if (h2) { const nodeVersion = parseInt(process.versions.node.split(".")[0], 10);