From 9f85bf0d2141c7488d725aed6cc20b9a00a47e70 Mon Sep 17 00:00:00 2001 From: Christian Voshage Date: Thu, 10 Sep 2026 16:40:24 +0200 Subject: [PATCH] fix: Correct flexBundle flag in generateFlexBundle * the flag should only be used for the flexibilityBundle * to improve the client performance, it now also only sets the flag in case of a mandatory early loading of the bundle. Otherwise, the file can be loaded from the -preload --- .../processors/bundlers/flexChangesBundler.js | 47 +- .../bundlers/generateFlexChangesBundle.js | 42 +- .../build/application.i/dest/Component.js | 11 +- .../build/application.i/dest/manifest.json | 3 +- .../processors/bundlers/flexChangesBundler.js | 14 +- .../bundlers/generateFlexChangesBundle.js | 1124 ++++++++--------- 6 files changed, 643 insertions(+), 598 deletions(-) diff --git a/packages/builder/lib/processors/bundlers/flexChangesBundler.js b/packages/builder/lib/processors/bundlers/flexChangesBundler.js index b428e0ef789..b5e619da2b9 100644 --- a/packages/builder/lib/processors/bundlers/flexChangesBundler.js +++ b/packages/builder/lib/processors/bundlers/flexChangesBundler.js @@ -7,6 +7,20 @@ import {createResource} from "@ui5/fs/resourceFactory"; * @module @ui5/builder/processors/bundlers/flexChangesBundler */ +/** + * Result of the flex changes bundling process. + * + * @public + * @typedef {object} FlexChangesBundlerResult + * @property {boolean|undefined} flexBundle Flag indicating whether a flexibility bundle was created. + * - `true`: a flexibility bundle was created and at least one annotation change is included + * - `undefined`: a flexibility bundle was created, but does not contain any annotation change, + * so the client should not enforce an early dedicated loading of the bundle + * - `false`: no flexibility bundle was created (no changes to bundle, e.g. only filtered + * app-descriptor changes were provided) + * @property {@ui5/fs/Resource[]} bundleResources List of created flex changes bundle resources + */ + /** * Bundles all supplied changes. * @@ -18,14 +32,18 @@ import {createResource} from "@ui5/fs/resourceFactory"; * @param {@ui5/fs/Resource[]} parameters.resources List of resources to be processed * @param {object} parameters.options Options * @param {string} parameters.options.pathPrefix Prefix for bundle path - * @param {string} parameters.options.hasFlexBundleVersion true if minUI5Version >= 1.73 + * @param {boolean} parameters.options.hasFlexBundleVersion true if minUI5Version >= 1.73 * and create flexibility-bundle.json * @param {object} [parameters.existingFlexBundle={}] Object with existing flexibility-bundle.json * to merge with new changes - * @returns {Promise<@ui5/fs/Resource[]>} Promise resolving with flex changes bundle resources + * @returns {Promise} Promise resolving with an object containing + * the flexBundle flag and the created flex changes bundle resources */ export default function({resources, options: {pathPrefix, hasFlexBundleVersion}, existingFlexBundle = {}}) { let bundleName = "changes-bundle.json"; + // Default to `false`: no bundle content will be written. + // Updated below to `true` (annotation changes present) or `undefined` otherwise + let flexBundle = false; function sortByTimeStamp(a, b) { return a.creation > b.creation ? 1 : -1; @@ -34,8 +52,8 @@ export default function({resources, options: {pathPrefix, hasFlexBundleVersion}, /** * bundle changes resource to json string * - * @param {Array} changesContent Array of resources files - * @returns {string} Json sting of changes and control variants + * @param {Array} changesContent Array of resources files (changes, variants, etc) + * @returns {string} Json string of changes and control variants */ function sortAndStringifyInFlexFormat(changesContent) { changesContent = changesContent.sort(sortByTimeStamp); @@ -81,6 +99,15 @@ export default function({resources, options: {pathPrefix, hasFlexBundleVersion}, } }); + const hasBundleContent = + changes.length > 0 || + variantDependentControlChanges.length > 0 || + compVariants.length > 0 || + variants.length > 0 || + variantChanges.length > 0 || + variantManagementChanges.length > 0 || + annotationChanges.length > 0; + if (!hasFlexBundleVersion && ( compVariants.length != 0 || variants.length != 0 || @@ -93,6 +120,13 @@ export default function({resources, options: {pathPrefix, hasFlexBundleVersion}, "There are some files in the changes folder supported only with a UI5 version 1.73 and above. " + "Please update the minUI5Version in the manifest.json to 1.73 or higher"); } + + if (annotationChanges.length > 0) { + flexBundle = true; + } else if (hasBundleContent) { + flexBundle = undefined; + } + // create changes-bundle.json if (!hasFlexBundleVersion) { return JSON.stringify(changes); @@ -149,6 +183,9 @@ export default function({resources, options: {pathPrefix, hasFlexBundleVersion}, string: changesContent })); } - return result; + return { + bundleResources: result, + flexBundle + }; }); } diff --git a/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js b/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js index da2e5cfeb9a..8c2f8b0feca 100644 --- a/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js +++ b/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js @@ -10,7 +10,7 @@ import semver from "semver"; /* eslint "jsdoc/check-param-names": ["error", {"disableExtraPropertyReporting":true}] */ /** - * Task to create changesBundle.json file containing all changes stored in the /changes folder for easier consumption + * Task to create changes-bundle.json file containing all changes stored in the /changes folder for easier consumption * at runtime. * If a change bundle is created, "sap.ui.fl" is added as a dependency to the manifest.json if not already present - * if the dependency is already listed but lazy-loaded, lazy loading is disabled. @@ -38,12 +38,24 @@ export default async function({workspace, taskUtil, options = {}}) { pathPrefix = `/resources/${namespace}`; } - function updateJson(data, bBundleCreated) { + /** + * Update the manifest.json JSON in-place with the flexBundle flag and, if a bundle was + * written, ensure the sap.ui.fl dependency is present (and lazy loading disabled). + * + * @param {object} data Parsed manifest.json content + * @param {boolean|undefined} flexBundleFlag Result flag from the flex changes bundler: + * - `true`: bundle written including annotation changes + * - `undefined`: bundle written but without annotation changes + * - `false`: no bundle written + */ + function updateJson(data, flexBundleFlag) { data["sap.ui5"] = data["sap.ui5"] || {}; // explicit set the flag to inform the runtime if a bundle is present before the preload can be interpreted - data["sap.ui5"].flexBundle = bBundleCreated; + data["sap.ui5"].flexBundle = flexBundleFlag; - if (bBundleCreated) { + // A bundle has been written whenever the flag is not explicitly `false` - this includes the `undefined`. + // In all these cases the sap.ui.fl library dependency is required. + if (flexBundleFlag !== false) { // ensure the existence of the libs section in the dependencies data["sap.ui5"].dependencies = data["sap.ui5"].dependencies || {}; const mLibs = data["sap.ui5"].dependencies.libs = data["sap.ui5"].dependencies.libs || {}; @@ -61,7 +73,7 @@ export default async function({workspace, taskUtil, options = {}}) { } } - async function updateManifestWithFlDependencyAndFlexBundleFlag(bBundleCreated) { + async function updateManifestWithFlDependencyAndFlexBundleFlag(flexBundleFlag) { const manifestResource = await workspace.byPath(`${pathPrefix}/manifest.json`); if (!manifestResource) { log.verbose("No manifest.json found, skipping update of sap.ui.fl dependency and flexBundle flag"); @@ -69,7 +81,7 @@ export default async function({workspace, taskUtil, options = {}}) { } const manifestContent = JSON.parse(await manifestResource.getString()); - updateJson(manifestContent, bBundleCreated); + updateJson(manifestContent, flexBundleFlag); manifestResource.setString(JSON.stringify(manifestContent, null, "\t")); await workspace.write(manifestResource); @@ -99,7 +111,7 @@ export default async function({workspace, taskUtil, options = {}}) { "ctrl_variant_change,ctrl_variant_management_change}" ); - let bBundleCreated = false; + let flexChangesBundleResult; if (allResources.length > 0) { const versionArray = await readManifestMinUI5Version(); @@ -114,7 +126,7 @@ export default async function({workspace, taskUtil, options = {}}) { flexBundle = JSON.parse(await flexBundleResource.getString()); } } - const processedResources = await flexChangesBundler({ + flexChangesBundleResult = await flexChangesBundler({ resources: allResources, options: { pathPrefix, @@ -122,12 +134,13 @@ export default async function({workspace, taskUtil, options = {}}) { }, existingFlexBundle: flexBundle }); - await Promise.all(processedResources.map((resource) => { + + const bundleResources = flexChangesBundleResult.bundleResources; + + await Promise.all(bundleResources.map((resource) => { log.verbose("Writing flexibility changes bundle"); return workspace.write(resource); })); - // Add the sap.ui.fl dependency if a bundle has been created - bBundleCreated = processedResources.length > 0; // Do not write bundled source files to build result if (taskUtil) { @@ -137,6 +150,9 @@ export default async function({workspace, taskUtil, options = {}}) { } } - // Always append the flexBundle flag to the manifest.json, even if no bundle was created - await updateManifestWithFlDependencyAndFlexBundleFlag(bBundleCreated); + // Always append the flexBundle flag to the manifest.json, even if no bundle was created. + // When no flex changes have been processed at all, no bundle is created and the flag + // is set to `false`. + const flexBundleFlag = flexChangesBundleResult ? flexChangesBundleResult.flexBundle : false; + await updateManifestWithFlDependencyAndFlexBundleFlag(flexBundleFlag); } diff --git a/packages/builder/test/expected/build/application.i/dest/Component.js b/packages/builder/test/expected/build/application.i/dest/Component.js index 5d50a2effaf..c75c4d9080b 100644 --- a/packages/builder/test/expected/build/application.i/dest/Component.js +++ b/packages/builder/test/expected/build/application.i/dest/Component.js @@ -1,2 +1,9 @@ -sap.ui.define(["sap/ui/core/UIComponent"],function(n){"use strict";return n.extend("application.i.Component",{metadata:{manifest:"json"}})}); -//# sourceMappingURL=Component.js.map \ No newline at end of file +//@ui5-bundle application/i/Component-preload.js +sap.ui.predefine("application/i/Component", ["sap/ui/core/UIComponent"],function(n){"use strict";return n.extend("application.i.Component",{metadata:{manifest:"json"}})}); +sap.ui.predefine("application/i/changes/coding/MyExtension", [],function(){return{}}); +sap.ui.require.preload({ + "application/i/changes/changes-bundle.json":'[{"fileName":"id_456_addField","fileType":"change","changeType":"hideControl","component":"application.i.Component","content":{},"selector":{"id":"control1"},"layer":"VENDOR","texts":{},"namespace":"apps/application.i.Component/changes","creation":"2023-10-30T13:52:40.4754350Z","originalLanguage":"","conditions":{},"support":{"generator":"did it","user":"SAP"}},{"fileName":"id_123_addField","fileType":"change","changeType":"hideControl","component":"application.i.Component","content":{},"selector":{"id":"control1"},"layer":"CUSTOMER","texts":{},"namespace":"apps/application.i.Component/changes","creation":"2025-10-30T13:52:40.4754350Z","originalLanguage":"","conditions":{},"support":{"generator":"did it","user":"Max Mustermann"}}]', + "application/i/changes/fragments/MyFragment.fragment.xml":'', + "application/i/manifest.json":'{"_version":"1.1.0","sap.app":{"_version":"1.1.0","id":"application.i","type":"application","applicationVersion":{"version":"1.2.2"},"embeds":["embedded"],"title":"{{title}}"},"sap.ui5":{"dependencies":{"minUI5Version":"1.72","libs":{"sap.ui.layout":{},"sap.ui.core":{},"sap.m":{},"sap.ui.fl":{}}}}}' +}); +//# sourceMappingURL=Component-preload.js.map diff --git a/packages/builder/test/expected/build/application.i/dest/manifest.json b/packages/builder/test/expected/build/application.i/dest/manifest.json index 129073297a5..b40142f3040 100644 --- a/packages/builder/test/expected/build/application.i/dest/manifest.json +++ b/packages/builder/test/expected/build/application.i/dest/manifest.json @@ -21,7 +21,6 @@ "sap.m": {}, "sap.ui.fl": {} } - }, - "flexBundle": true + } } } \ No newline at end of file diff --git a/packages/builder/test/lib/processors/bundlers/flexChangesBundler.js b/packages/builder/test/lib/processors/bundlers/flexChangesBundler.js index b2c971c6cfd..6a8c7557aa0 100644 --- a/packages/builder/test/lib/processors/bundlers/flexChangesBundler.js +++ b/packages/builder/test/lib/processors/bundlers/flexChangesBundler.js @@ -4,7 +4,7 @@ import flexChangesBundler from "../../../../lib/processors/bundlers/flexChangesB test("flexChangesBundler with empty resources", async (t) => { const resources = []; const options = {}; - const aResult = await flexChangesBundler({resources, options}); + const {bundleResources: aResult} = await flexChangesBundler({resources, options}); t.deepEqual(aResult, [], "The result should be an empty array"); }); @@ -98,7 +98,7 @@ test("flexChangesBundler with 2 changes", async (t) => { const options = { pathPrefix: "/mypath" }; - const aResult = await flexChangesBundler({resources, options}); + const {bundleResources: aResult} = await flexChangesBundler({resources, options}); t.is(aResult.length, 1, "There should be only one element"); const oResult = aResult[0]; @@ -131,7 +131,7 @@ test("includes annotation_changes in flexibility-bundle when hasFlexBundleVersio }]; const options = {pathPrefix: "/mypath", hasFlexBundleVersion: true}; - const aResult = await flexChangesBundler({resources, options}); + const {bundleResources: aResult} = await flexChangesBundler({resources, options}); t.is(aResult.length, 1); const parsed = JSON.parse(await aResult[0].getString()); t.true(Array.isArray(parsed.annotationChanges)); @@ -450,7 +450,7 @@ test("flexChangesBundler has ctrl_variant and hasFlexBundleVersion = true", asyn pathPrefix: "/mypath", hasFlexBundleVersion: true }; - const aResult = await flexChangesBundler({resources, options}); + const {bundleResources: aResult} = await flexChangesBundler({resources, options}); t.is(aResult.length, 1, "There should be only one element"); const oResult = aResult[0]; @@ -993,7 +993,7 @@ test("flexChangesBundler with existing flexibility-bundle.json", async (t) => { pathPrefix: "/mypath", hasFlexBundleVersion: true }; - const aResult = await flexChangesBundler({resources, options, existingFlexBundle}); + const {bundleResources: aResult} = await flexChangesBundler({resources, options, existingFlexBundle}); t.is(aResult.length, 1, "There should be only one element"); const oResult = aResult[0]; @@ -1296,7 +1296,7 @@ test("flexChangesBundler with existing flexibility-bundle.json and missing/wrong pathPrefix: "/mypath", hasFlexBundleVersion: true }; - const aResult = await flexChangesBundler({resources, options, existingFlexBundle}); + const {bundleResources: aResult} = await flexChangesBundler({resources, options, existingFlexBundle}); t.is(aResult.length, 1, "There should be only one element"); const oResult = aResult[0]; @@ -1407,7 +1407,7 @@ test("flexChangesBundler with existing flexibility-bundle.json and version lower pathPrefix: "/mypath", hasFlexBundleVersion: false }; - const aResult = await flexChangesBundler({resources, options, existingFlexBundle}); + const {bundleResources: aResult} = await flexChangesBundler({resources, options, existingFlexBundle}); t.is(aResult.length, 1, "There should be only one element"); const oResult = aResult[0]; diff --git a/packages/builder/test/lib/tasks/bundlers/generateFlexChangesBundle.js b/packages/builder/test/lib/tasks/bundlers/generateFlexChangesBundle.js index 0f08f4d53d6..d50b5e18cb5 100644 --- a/packages/builder/test/lib/tasks/bundlers/generateFlexChangesBundle.js +++ b/packages/builder/test/lib/tasks/bundlers/generateFlexChangesBundle.js @@ -2,7 +2,18 @@ import test from "ava"; import sinon from "sinon"; import generateFlexChangesBundle from "../../../../lib/tasks/bundlers/generateFlexChangesBundle.js"; - +// ----------------------------------------------------------------------------- +// Test helpers +// ----------------------------------------------------------------------------- + +/** + * Creates a lightweight mock of a @ui5/fs Resource holding an arbitrary + * (JSON-serializable) content and an optional path. + * + * @param {object|Array|string} content Content held by the resource + * @param {string} [path="unknown"] Path returned by getPath() + * @returns {object} A resource-like stub + */ function createPlaceholderResource(content, path = "unknown") { let currentContent = content; return { @@ -16,639 +27,614 @@ function createPlaceholderResource(content, path = "unknown") { }; } -function createPlaceholderWorkspace(changes, manifest, flexBundle) { +/** + * Creates a manifest.json content with the given minUI5Version and optional + * extra content in the sap.ui5 section. + * + * @param {string|string[]} [minUI5Version="1.75.0"] The minUI5Version value + * @param {object} [extraSapUi5={}] Additional keys merged into sap.ui5 + * @returns {object} A manifest.json content object + */ +function createManifest(minUI5Version = "1.75.0", extraSapUi5 = {}) { return { - byGlob: async () => changes.map(createPlaceholderResource), - byPath: async (path) => { - if ( path.includes("manifest.json") ) { - return createPlaceholderResource(manifest); - } else if ( path.includes("flexibility-bundle.json")) { - return createPlaceholderResource(flexBundle); - } - }, - write: () => { - throw new Error("Function 'write' is not implemented"); - } - }; -} - -["1.120.0", ["1.120.0", "2.0.0"]].forEach((minVersion) => { - test.serial(`execute flexChangeBundler with the minVersion: ${minVersion}`, async (t) => { - const changeList = [ - { - "fileName": "id_1504764957625_7_rename1", - "fileType": "change", - "changeType": "rename", - "reference": "rta.performance.Component", - "packageName": "$TMP", - "content": { - "originalControlType": "sap.m.Label" - }, - "selector": { - "id": "initialLabel", - "idIsLocal": false - }, - "layer": "CUSTOMER", - "texts": { - "newText": { - "value": "rename_0", - "type": "XFLD" - } - }, - "namespace": "apps/MyComponent/changes/", - "creation": "2017-10-06T11:54:55.238Z", - "originalLanguage": "EN", - "conditions": {}, - "context": "", - "support": { - "generator": "Change.createInitialFileContent", - "service": "", - "user": "", - "sapui5Version": "1.51.0-SNAPSHOT" - }, - "dependentSelector": {}, - "validAppVersions": { - "creation": "1.0.0", - "from": "1.0.0", - "to": "1.0.0" - } - } - ]; - const existingChangeList = [ - { - "fileName": "id_1504764957630_7_rename2", - "fileType": "change", - "changeType": "rename", - "reference": "rta.performance.Component", - "packageName": "$TMP", - "content": { - "originalControlType": "sap.m.Label" - }, - "selector": { - "id": "initialLabel", - "idIsLocal": false - }, - "layer": "USER", - "texts": { - "newText": { - "value": "rename_5", - "type": "XFLD" - } - }, - "namespace": "apps/MyComponent/changes/", - "creation": "2017-09-01T11:54:55.238Z", - "originalLanguage": "EN", - "conditions": {}, - "context": "", - "support": { - "generator": "Change.createInitialFileContent", - "service": "", - "user": "", - "sapui5Version": "1.51.0-SNAPSHOT" - }, - "dependentSelector": {}, - "validAppVersions": { - "creation": "1.0.0", - "from": "1.0.0", - "to": "1.0.0" - } - } - ]; - const manifest = { - "sap.ui5": { - dependencies: { - minUI5Version: minVersion - } - } - }; - - const flexBundle = { - "annotationChanges": [], - "changes": existingChangeList, - "compVariants": [], - "variantChanges": [], - "variantDependentControlChanges": [], - "variantManagementChanges": [], - "variants": [] - }; - - const flexBundleMerge = { - "annotationChanges": [], - "changes": existingChangeList.concat(changeList), - "compVariants": [], - "variantChanges": [], - "variantDependentControlChanges": [], - "variantManagementChanges": [], - "variants": [] - }; - - const placeholderWorkspace = createPlaceholderWorkspace(changeList, manifest, flexBundle); - const stub = sinon.stub(placeholderWorkspace, "write").returnsArg(0); - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, - taskUtil: false, - options: { - projectNamespace: "mypath" - } - }); - - const content = JSON.parse(await stub.getCall(0).args[0].getString()); - t.deepEqual(content, flexBundleMerge, "Result must contain the same content"); - - const path = await stub.getCall(0).args[0].getPath(); - t.is(path, "/resources/mypath/changes/flexibility-bundle.json"); - - const writtenManifest = JSON.parse(await stub.getCall(1).args[0].getString()); - t.deepEqual(writtenManifest, { - "sap.ui5": { - dependencies: { - minUI5Version: minVersion, - libs: { - "sap.ui.fl": {} - } - }, - flexBundle: true - } - }, "Result must contain the same content"); - }); -}); - -["1.70.0", ["1.70.0", "2.0.0"]].forEach((minVersion) => { - test.serial(`execute flexChangeBundler with the minVersion < 1.73: ${minVersion}`, async (t) => { - const manifest = { - "sap.ui5": { - dependencies: { - minUI5Version: minVersion - } - } - }; - - const changeList = [ - { - "fileName": "id_1504764957625_7_rename1", - "fileType": "change", - "changeType": "rename", - "reference": "rta.performance.Component", - "packageName": "$TMP", - "content": { - "originalControlType": "sap.m.Label" - }, - "selector": { - "id": "initialLabel", - "idIsLocal": false - }, - "layer": "CUSTOMER", - "texts": { - "newText": { - "value": "rename_0", - "type": "XFLD" - } - }, - "namespace": "apps/MyComponent/changes/", - "creation": "2017-10-06T11:54:55.238Z", - "originalLanguage": "EN", - "conditions": {}, - "context": "", - "support": { - "generator": "Change.createInitialFileContent", - "service": "", - "user": "", - "sapui5Version": "1.51.0-SNAPSHOT" - }, - "dependentSelector": {}, - "validAppVersions": { - "creation": "1.0.0", - "from": "1.0.0", - "to": "1.0.0" - } - } - ]; - - const flexBundle = { - "changes": [], - "compVariants": [], - "variantChanges": [], - "variantDependentControlChanges": [], - "variantManagementChanges": [], - "variants": [] - }; - - const placeholderWorkspace = createPlaceholderWorkspace(changeList, manifest, flexBundle); - const stub = sinon.stub(placeholderWorkspace, "write").returnsArg(0); - - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, - taskUtil: false, - options: { - projectNamespace: "mypath" - } - }); - - const content = JSON.parse(await stub.getCall(0).args[0].getString()); - t.deepEqual(content, changeList, "Result must contain the same content"); - - const path = await stub.getCall(0).args[0].getPath(); - t.is(path, "/resources/mypath/changes/changes-bundle.json"); - }); -}); - -test("flexBundle property set to true when bundle is created", async (t) => { - const manifest = { "_version": "1.58.0", "sap.app": { "id": "sap.ui.demo.app", "type": "application" }, - "sap.ui5": { + "sap.ui5": Object.assign({ "dependencies": { - "minUI5Version": "1.75.0" + minUI5Version } - } + }, extraSapUi5) }; +} - const changeList = [{ - "fileName": "test_change", - "fileType": "change", - "changeType": "rename", - "reference": "test.Component", - "content": {}, - "selector": {"id": "testId"}, - "layer": "CUSTOMER" - }]; - - const placeholderWorkspace = { - byGlob: async () => changeList.map(createPlaceholderResource), +/** + * Creates a stub of a UI5 workspace providing the given changes, manifest and + * optionally an existing flexibility-bundle.json. + * + * @param {Array} changeList Changes returned by byGlob + * @param {object|null} manifest Manifest.json content (or null if it should not exist) + * @param {object} [options={}] + * @param {object|null} [options.existingFlexBundle=null] Existing flexibility-bundle.json + * @param {boolean} [options.manifestExists=true] Whether manifest.json should be resolved + * @returns {object} A workspace-like stub with `byGlob`, `byPath` and `write` stubs + */ +function createStubWorkspace(changeList, manifest, { + existingFlexBundle = null, + manifestExists = true +} = {}) { + return { + byGlob: async () => changeList.map((c) => createPlaceholderResource(c)), byPath: async (path) => { if (path.includes("manifest.json")) { - return createPlaceholderResource(manifest, path); + return manifestExists ? createPlaceholderResource(manifest, path) : null; } else if (path.includes("flexibility-bundle.json")) { - // Return non-null to indicate file exists - return createPlaceholderResource({}, path); + return existingFlexBundle ? createPlaceholderResource(existingFlexBundle, path) : null; } return null; }, write: sinon.stub().returnsArg(0) }; +} - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, +/** + * Runs the task with default projectNamespace and no taskUtil. + * + * @param {object} workspace Workspace stub + * @param {object} [extra={}] Additional parameters passed to the task + * @returns {Promise} Resolves when the task has finished + */ +function runTask(workspace, extra = {}) { + return generateFlexChangesBundle({ + workspace, taskUtil: false, - options: { - projectNamespace: "sap/ui/demo/app" - } + options: {projectNamespace: "sap/ui/demo/app"}, + ...extra }); +} - // Check that manifest was updated with flexBundle: true - t.true(placeholderWorkspace.write.callCount > 0, "workspace.write should be called"); - - // Find the manifest write call - let manifestCall; - for (let i = 0; i < placeholderWorkspace.write.callCount; i++) { - const call = placeholderWorkspace.write.getCall(i); - const path = call.args[0].getPath ? await call.args[0].getPath() : "unknown"; - if (path && path.includes("manifest.json")) { - manifestCall = call; - break; +/** + * Finds the workspace.write call whose resource path matches the predicate and + * returns its resource. Returns undefined when no matching call exists. + * + * @param {object} workspace The workspace stub with a sinon stub `write` + * @param {function(string):boolean} predicate Path predicate + * @returns {Promise} The matching resource (or undefined) + */ +async function findWrittenResource(workspace, predicate) { + for (let i = 0; i < workspace.write.callCount; i++) { + const resource = workspace.write.getCall(i).args[0]; + const path = resource.getPath ? await resource.getPath() : ""; + if (predicate(path)) { + return resource; } } + return undefined; +} + +/** + * Returns the parsed manifest.json content that was written by the task. + * + * @param {object} workspace Workspace stub + * @returns {Promise} Parsed manifest.json content or undefined + */ +async function getWrittenManifest(workspace) { + const resource = await findWrittenResource(workspace, (p) => p.includes("manifest.json")); + return resource ? JSON.parse(await resource.getString()) : undefined; +} - t.truthy(manifestCall, "Manifest should be written"); - const manifestContent = JSON.parse(await manifestCall.args[0].getString()); +/** + * Returns the parsed flex/changes bundle content that was written by the task. + * + * @param {object} workspace Workspace stub + * @returns {Promise} Parsed bundle content or undefined + */ +async function getWrittenBundle(workspace) { + const resource = await findWrittenResource(workspace, + (p) => p.includes("flexibility-bundle.json") || p.includes("changes-bundle.json")); + return resource ? JSON.parse(await resource.getString()) : undefined; +} - t.truthy(manifestContent["sap.ui5"], "sap.ui5 section should exist"); - t.true(manifestContent["sap.ui5"].flexBundle, "flexBundle should be set to true when bundle is created"); - t.deepEqual(manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"], {}, "sap.ui.fl dependency should be added"); -}); +/** + * Returns the collection of paths that the task wrote to the workspace. + * + * @param {object} workspace Workspace stub + * @returns {Promise} Paths of all written resources + */ +async function getWrittenPaths(workspace) { + const paths = []; + for (let i = 0; i < workspace.write.callCount; i++) { + const resource = workspace.write.getCall(i).args[0]; + paths.push(resource.getPath ? await resource.getPath() : ""); + } + return paths; +} -test("flexBundle property set to true when bundle is created even without existing flexibility-bundle.json", - async (t) => { - const manifest = { - "_version": "1.58.0", - "sap.app": { - "id": "sap.ui.demo.app", - "type": "application" - }, +/** + * Builds a fully populated regular change fixture (fileType: "change") with + * overridable values. Suitable for the pre-existing round-trip tests. + * + * @param {object} [overrides={}] Values to merge on top of the default fixture + * @returns {object} A change object + */ +function createFullChangeFixture(overrides = {}) { + return Object.assign({ + "fileName": "id_1504764957625_7_rename1", + "fileType": "change", + "changeType": "rename", + "reference": "rta.performance.Component", + "packageName": "$TMP", + "content": {"originalControlType": "sap.m.Label"}, + "selector": {"id": "initialLabel", "idIsLocal": false}, + "layer": "CUSTOMER", + "texts": {"newText": {"value": "rename_0", "type": "XFLD"}}, + "namespace": "apps/MyComponent/changes/", + "creation": "2017-10-06T11:54:55.238Z", + "originalLanguage": "EN", + "conditions": {}, + "context": "", + "support": { + "generator": "Change.createInitialFileContent", + "service": "", + "user": "", + "sapui5Version": "1.51.0-SNAPSHOT" + }, + "dependentSelector": {}, + "validAppVersions": {"creation": "1.0.0", "from": "1.0.0", "to": "1.0.0"} + }, overrides); +} + +/** + * Builds a minimal change fixture. `fileType` defaults to `"change"`. + * + * @param {object} [overrides={}] Values overriding the defaults + * @returns {object} A change object + */ +function createChange(overrides = {}) { + return Object.assign({ + fileName: "test_change", + fileType: "change", + changeType: "rename", + reference: "test.Component", + content: {}, + selector: {id: "testId"}, + layer: "CUSTOMER" + }, overrides); +} + +/** + * Standard "empty" content for existing flexibility-bundle.json in older layouts. + * + * @returns {object} An empty flexibility-bundle content + */ +function emptyExistingFlexBundle() { + return { + annotationChanges: [], + changes: [], + compVariants: [], + variantChanges: [], + variantDependentControlChanges: [], + variantManagementChanges: [], + variants: [] + }; +} + +// ----------------------------------------------------------------------------- +// Round-trip tests for flexibility-bundle.json / changes-bundle.json paths +// ----------------------------------------------------------------------------- + +["1.120.0", ["1.120.0", "2.0.0"]].forEach((minVersion) => { + test.serial(`execute flexChangeBundler with the minVersion: ${minVersion}`, async (t) => { + const changeList = [createFullChangeFixture()]; + const existingChangeList = [createFullChangeFixture({ + fileName: "id_1504764957630_7_rename2", + layer: "USER", + texts: {newText: {value: "rename_5", type: "XFLD"}}, + creation: "2017-09-01T11:54:55.238Z" + })]; + + const manifest = {"sap.ui5": {dependencies: {minUI5Version: minVersion}}}; + const existingFlexBundle = Object.assign(emptyExistingFlexBundle(), + {changes: existingChangeList}); + const expectedBundle = Object.assign(emptyExistingFlexBundle(), + {changes: existingChangeList.concat(changeList)}); + + const workspace = createStubWorkspace(changeList, manifest, {existingFlexBundle}); + await runTask(workspace, {options: {projectNamespace: "mypath"}}); + + const bundleResource = await findWrittenResource(workspace, + (p) => p.includes("flexibility-bundle.json")); + t.is(bundleResource.getPath(), "/resources/mypath/changes/flexibility-bundle.json"); + t.deepEqual(JSON.parse(await bundleResource.getString()), expectedBundle, + "Merged flexibility-bundle contains new+existing changes"); + + const writtenManifest = await getWrittenManifest(workspace); + t.deepEqual(writtenManifest, { "sap.ui5": { - "dependencies": { - "minUI5Version": "1.75.0" - } - } - }; - - const changeList = [{ - "fileName": "test_change", - "fileType": "change", - "changeType": "rename", - "reference": "test.Component", - "content": {}, - "selector": {"id": "testId"}, - "layer": "CUSTOMER" - }]; - - const placeholderWorkspace = { - byGlob: async () => changeList.map(createPlaceholderResource), - byPath: async (path) => { - if (path.includes("manifest.json")) { - return createPlaceholderResource(manifest, path); - } else if (path.includes("flexibility-bundle.json")) { - // Return null to indicate file does not exist - return null; + dependencies: { + minUI5Version: minVersion, + libs: {"sap.ui.fl": {}} } - return null; - }, - write: sinon.stub().returnsArg(0) - }; - - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, - taskUtil: false, - options: { - projectNamespace: "sap/ui/demo/app" - } - }); - - // Check that manifest was updated with flexBundle: true - t.true(placeholderWorkspace.write.callCount > 0, "workspace.write should be called"); - - // Find the manifest write call - let manifestCall; - for (let i = 0; i < placeholderWorkspace.write.callCount; i++) { - const call = placeholderWorkspace.write.getCall(i); - const path = call.args[0].getPath ? await call.args[0].getPath() : "unknown"; - if (path && path.includes("manifest.json")) { - manifestCall = call; - break; } - } - - t.truthy(manifestCall, "Manifest should be written"); - const manifestContent = JSON.parse(await manifestCall.args[0].getString()); + }, "Manifest gets the sap.ui.fl dependency"); + }); +}); - t.truthy(manifestContent["sap.ui5"], "sap.ui5 section should exist"); - t.true(manifestContent["sap.ui5"].flexBundle, "flexBundle should be set to true when bundle is created"); - t.deepEqual( - manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"], {}, "sap.ui.fl dependency should be added"); +["1.70.0", ["1.70.0", "2.0.0"]].forEach((minVersion) => { + test.serial(`execute flexChangeBundler with the minVersion < 1.73: ${minVersion}`, async (t) => { + const changeList = [createFullChangeFixture()]; + const manifest = {"sap.ui5": {dependencies: {minUI5Version: minVersion}}}; + + const workspace = createStubWorkspace(changeList, manifest, + {existingFlexBundle: emptyExistingFlexBundle()}); + await runTask(workspace, {options: {projectNamespace: "mypath"}}); + + const bundleResource = await findWrittenResource(workspace, + (p) => p.includes("changes-bundle.json")); + t.is(bundleResource.getPath(), "/resources/mypath/changes/changes-bundle.json"); + t.deepEqual(JSON.parse(await bundleResource.getString()), changeList, + "Plain changes-bundle.json contains the new change"); }); +}); -test("sap.ui.fl dependency disables lazy loading if already present", async (t) => { - const manifest = { - "_version": "1.58.0", - "sap.app": { - "id": "sap.ui.demo.app", - "type": "application" - }, - "sap.ui5": { - "dependencies": { - "minUI5Version": "1.75.0", - "libs": { - "sap.ui.fl": { - "lazy": true - } - } - } - } - }; +// ----------------------------------------------------------------------------- +// Manifest update tests (based on flexBundle flag semantics) +// ----------------------------------------------------------------------------- - const changeList = [{ - "fileName": "test_change", - "fileType": "change", - "changeType": "rename", - "reference": "test.Component", - "content": {}, - "selector": {"id": "testId"}, - "layer": "CUSTOMER" - }]; - - const placeholderWorkspace = { - byGlob: async () => changeList.map((change) => createPlaceholderResource(change)), - byPath: async (path) => { - if (path.includes("manifest.json")) { - return createPlaceholderResource(manifest, path); - } else if (path.includes("flexibility-bundle.json")) { - return createPlaceholderResource({}, path); - } - return null; - }, - write: sinon.stub().returnsArg(0) - }; +test("flexBundle undefined when bundle is created without an annotation change (existing flexBundle file)", + async (t) => { + const workspace = createStubWorkspace( + [createChange()], + createManifest("1.75.0"), + {existingFlexBundle: {}} + ); + + await runTask(workspace); + + const manifestContent = await getWrittenManifest(workspace); + t.truthy(manifestContent, "Manifest is written"); + t.false("flexBundle" in manifestContent["sap.ui5"], + "flexBundle is undefined (absent after JSON serialization) when no annotation change is present"); + t.deepEqual(manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"], {}, + "sap.ui.fl dependency is added"); + }); - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, - taskUtil: false, - options: { - projectNamespace: "sap/ui/demo/app" - } +test("flexBundle undefined when bundle is created without existing bundle and without annotation change", + async (t) => { + const workspace = createStubWorkspace( + [createChange()], + createManifest("1.75.0") + ); + + await runTask(workspace); + + const manifestContent = await getWrittenManifest(workspace); + t.truthy(manifestContent, "Manifest is written"); + t.false("flexBundle" in manifestContent["sap.ui5"], + "flexBundle is undefined when a bundle is created without annotation change"); + t.deepEqual(manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"], {}, + "sap.ui.fl dependency is added"); }); - // Find the manifest write call - let manifestCall; - for (let i = 0; i < placeholderWorkspace.write.callCount; i++) { - const call = placeholderWorkspace.write.getCall(i); - const path = call.args[0].getPath ? await call.args[0].getPath() : "unknown"; - if (path && path.includes("manifest.json")) { - manifestCall = call; - break; +test("flexBundle true when bundle is created without existing bundle but with an annotation change", + async (t) => { + const workspace = createStubWorkspace( + [createChange({fileType: "annotation_change"})], + createManifest("1.75.0") + ); + + await runTask(workspace); + + const manifestContent = await getWrittenManifest(workspace); + t.truthy(manifestContent, "Manifest is written"); + t.true(manifestContent["sap.ui5"].flexBundle, + "flexBundle is true when annotation changes are bundled"); + t.deepEqual(manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"], {}, + "sap.ui.fl dependency is added"); + }); + +test("sap.ui.fl dependency disables lazy loading if already present", async (t) => { + const manifest = createManifest("1.75.0", { + dependencies: { + minUI5Version: "1.75.0", + libs: {"sap.ui.fl": {lazy: true}} } - } + }); + const workspace = createStubWorkspace([createChange()], manifest, {existingFlexBundle: {}}); - t.truthy(manifestCall, "Manifest should be written"); - const manifestContent = JSON.parse(await manifestCall.args[0].getString()); + await runTask(workspace); - const sapUiFlDependency = manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"]; - t.false(sapUiFlDependency.lazy, "sap.ui.fl lazy loading should be disabled when bundle is created"); + const manifestContent = await getWrittenManifest(workspace); + t.truthy(manifestContent, "Manifest is written"); + t.false(manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"].lazy, + "sap.ui.fl lazy loading is disabled when a bundle is created"); }); test("manifest updated with flexBundle false when no changes exist", async (t) => { - const manifest = { - "_version": "1.58.0", - "sap.app": { - "id": "sap.ui.demo.app", - "type": "application" - }, - "sap.ui5": { - "dependencies": { - "minUI5Version": "1.75.0" - } - } - }; + const workspace = createStubWorkspace([], createManifest("1.75.0"), {existingFlexBundle: {}}); - const placeholderWorkspace = { - byGlob: async () => [], // No changes - byPath: async (path) => { - if (path.includes("manifest.json")) { - return createPlaceholderResource(manifest, path); - } else if (path.includes("flexibility-bundle.json")) { - // Even if file exists, task won't run without changes - return createPlaceholderResource({}, path); - } - return null; - }, - write: sinon.stub().returnsArg(0) - }; + await runTask(workspace); - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, - taskUtil: false, - options: { - projectNamespace: "sap/ui/demo/app" - } + t.is(workspace.write.callCount, 1, "Only the manifest is written when no changes exist"); + + const manifestContent = await getWrittenManifest(workspace); + t.truthy(manifestContent, "Manifest is written"); + t.false(manifestContent["sap.ui5"].flexBundle, + "flexBundle is false when no bundle is created"); + t.is(manifestContent["sap.ui5"].dependencies.libs, undefined, + "sap.ui.fl dependency is not added when no bundle is created"); +}); + +test("flexBundle overrides existing value when bundle is created", async (t) => { + // Pre-existing flexBundle:false should be overridden to true when an annotation change is bundled + const manifest = createManifest("1.75.0", { + dependencies: {minUI5Version: "1.75.0"}, + flexBundle: false }); + const workspace = createStubWorkspace( + [createChange({fileType: "annotation_change"})], + manifest, + {existingFlexBundle: {}} + ); + + await runTask(workspace); + + const manifestContent = await getWrittenManifest(workspace); + t.true(manifestContent["sap.ui5"].flexBundle, + "flexBundle is overridden to true when annotation changes are bundled"); + t.deepEqual(manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"], {}, + "sap.ui.fl dependency is added"); +}); - // Manifest should always be updated, even when no changes exist - t.is(placeholderWorkspace.write.callCount, 1, "workspace.write should be called to update manifest"); - - // Find the manifest write call - let manifestCall; - for (let i = 0; i < placeholderWorkspace.write.callCount; i++) { - const call = placeholderWorkspace.write.getCall(i); - const path = call.args[0].getPath ? await call.args[0].getPath() : "unknown"; - if (path && path.includes("manifest.json")) { - manifestCall = call; - break; - } - } +test("flexBundle undefined when minUI5Version is below 1.73 and only regular changes are provided", + async (t) => { + const workspace = createStubWorkspace( + [createChange()], + createManifest("1.72.0"), + {existingFlexBundle: {}} + ); + + await runTask(workspace); + + const manifestContent = await getWrittenManifest(workspace); + t.false("flexBundle" in manifestContent["sap.ui5"], + "flexBundle is undefined because no annotation change is present"); + t.deepEqual(manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"], {}, + "sap.ui.fl dependency is still added"); + }); - t.truthy(manifestCall, "Manifest should be written"); - const manifestContent = JSON.parse(await manifestCall.args[0].getString()); +// ----------------------------------------------------------------------------- +// Missing manifest.json handling +// ----------------------------------------------------------------------------- - t.truthy(manifestContent["sap.ui5"], "sap.ui5 section should exist"); - t.false(manifestContent["sap.ui5"].flexBundle, "flexBundle should be set to false when no bundle is created"); - t.is(manifestContent["sap.ui5"].dependencies.libs, undefined, - "sap.ui.fl dependency should not be added when no bundle is created"); +test("task does not fail when manifest.json is missing and no changes exist", async (t) => { + const workspace = createStubWorkspace([], null, {manifestExists: false}); + + await t.notThrowsAsync(() => runTask(workspace), + "Task does not fail without manifest.json and without changes"); + t.is(workspace.write.callCount, 0, "Nothing is written when manifest is missing and no changes"); }); -test("flexBundle property overrides existing value when bundle is created", async (t) => { - const manifest = { - "_version": "1.58.0", - "sap.app": { - "id": "sap.ui.demo.app", - "type": "application" - }, - "sap.ui5": { - "dependencies": { - "minUI5Version": "1.75.0" - }, - "flexBundle": false // Pre-existing value that should be overridden - } - }; +test("task does not fail when manifest.json is missing but changes exist", async (t) => { + const workspace = createStubWorkspace([createChange()], null, {manifestExists: false}); - const changeList = [{ - "fileName": "test_change", - "fileType": "change", - "changeType": "rename", - "reference": "test.Component", - "content": {}, - "selector": {"id": "testId"}, - "layer": "CUSTOMER" - }]; - - const placeholderWorkspace = { - byGlob: async () => changeList.map((change) => createPlaceholderResource(change)), - byPath: async (path) => { - if (path.includes("manifest.json")) { - return createPlaceholderResource(manifest, path); - } else if (path.includes("flexibility-bundle.json")) { - // Return non-null to indicate file exists - return createPlaceholderResource({}, path); - } - return null; - }, - write: sinon.stub().returnsArg(0) - }; + await t.notThrowsAsync(() => runTask(workspace), + "Task does not fail without manifest.json but with changes"); - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, - taskUtil: false, - options: { - projectNamespace: "sap/ui/demo/app" - } + const paths = await getWrittenPaths(workspace); + t.true(paths.some((p) => p.includes("flexibility-bundle.json")), + "flexibility-bundle.json is created"); + t.false(paths.some((p) => p.includes("manifest.json")), + "No manifest.json write when manifest is missing"); +}); + +// ----------------------------------------------------------------------------- +// Bundler behavior (via the task) — additional branch coverage +// ----------------------------------------------------------------------------- + +test("throws an error when variants are present but minUI5Version < 1.73", async (t) => { + const workspace = createStubWorkspace( + [createChange({fileType: "ctrl_variant"})], + createManifest("1.72.0") + ); + + await t.throwsAsync(() => runTask(workspace), + {message: /supported only with a UI5 version 1\.73 and above/}, + "Task throws with a helpful message"); +}); + +test("all fileType categories are correctly categorized in the flexibility-bundle", async (t) => { + const changeList = [ + createChange({fileName: "c1"}), + createChange({fileName: "v1", fileType: "variant"}), + createChange({fileName: "cv1", fileType: "ctrl_variant"}), + createChange({fileName: "cvc1", fileType: "ctrl_variant_change"}), + createChange({fileName: "cvmc1", fileType: "ctrl_variant_management_change"}), + createChange({fileName: "ac1", fileType: "annotation_change"}), + createChange({fileName: "vdc1", variantReference: "someVariant"}) + ]; + const workspace = createStubWorkspace(changeList, createManifest("1.75.0")); + + await runTask(workspace); + + const bundle = await getWrittenBundle(workspace); + t.truthy(bundle, "Bundle is written"); + t.deepEqual(bundle.changes.map((c) => c.fileName), ["c1"]); + t.deepEqual(bundle.compVariants.map((c) => c.fileName), ["v1"]); + t.deepEqual(bundle.variants.map((c) => c.fileName), ["cv1"]); + t.deepEqual(bundle.variantChanges.map((c) => c.fileName), ["cvc1"]); + t.deepEqual(bundle.variantManagementChanges.map((c) => c.fileName), ["cvmc1"]); + t.deepEqual(bundle.annotationChanges.map((c) => c.fileName), ["ac1"]); + t.deepEqual(bundle.variantDependentControlChanges.map((c) => c.fileName), ["vdc1"], + "Changes with variantReference are categorized as variantDependentControlChanges"); +}); + +[ + {title: "app-descriptor change (boolean true) is filtered out of the bundle", value: true}, + {title: "app-descriptor change (string 'true') is filtered out of the bundle", value: "true"} +].forEach(({title, value}) => { + test(title, async (t) => { + const changeList = [ + createChange({fileName: "appDesc", appDescriptorChange: value}), + createChange({fileName: "regular"}) + ]; + const workspace = createStubWorkspace(changeList, createManifest("1.75.0")); + + await runTask(workspace); + + const bundle = await getWrittenBundle(workspace); + t.deepEqual(bundle.changes.map((c) => c.fileName), ["regular"], + "Only the non-app-descriptor change is bundled"); }); +}); - // Check that manifest was updated and existing flexBundle: false was overridden to true - let manifestCall; - for (let i = 0; i < placeholderWorkspace.write.callCount; i++) { - const call = placeholderWorkspace.write.getCall(i); - const path = call.args[0].getPath ? await call.args[0].getPath() : "unknown"; - if (path && path.includes("manifest.json")) { - manifestCall = call; - break; - } - } +test("only app-descriptor changes → bundle is empty, flexBundle is false and no sap.ui.fl dependency", + async (t) => { + const workspace = createStubWorkspace( + [createChange({appDescriptorChange: true})], + createManifest("1.75.0") + ); + + await runTask(workspace); + + const bundle = await getWrittenBundle(workspace); + t.truthy(bundle, "Bundle is still written (with empty categories)"); + t.is(bundle.changes.length, 0); + t.is(bundle.annotationChanges.length, 0); + + const manifestContent = await getWrittenManifest(workspace); + t.false(manifestContent["sap.ui5"].flexBundle, + "flexBundle is false when only filtered app-descriptor changes are present"); + t.is(manifestContent["sap.ui5"].dependencies.libs, undefined, + "sap.ui.fl dependency is not added when no meaningful content is bundled"); + }); - t.truthy(manifestCall, "Manifest should be written"); - const manifestContent = JSON.parse(await manifestCall.args[0].getString()); +test("VENDOR layer changes get support.user rewritten to 'SAP'", async (t) => { + const workspace = createStubWorkspace( + [createChange({ + fileName: "vendorChange", + layer: "VENDOR", + support: {user: "someone.else@sap.com"} + })], + createManifest("1.75.0") + ); + + await runTask(workspace); + + const bundle = await getWrittenBundle(workspace); + t.is(bundle.changes[0].support.user, "SAP", + "VENDOR layer overrides support.user to 'SAP'"); +}); - t.true(manifestContent["sap.ui5"].flexBundle, "flexBundle should be overridden to true when bundle is created"); - t.deepEqual(manifestContent["sap.ui5"].dependencies.libs["sap.ui.fl"], {}, "sap.ui.fl dependency should be added"); +test("changes are sorted by creation timestamp", async (t) => { + const changeList = [ + createChange({fileName: "later", creation: "2020-05-05T10:00:00.000Z"}), + createChange({fileName: "earliest", creation: "2018-01-01T10:00:00.000Z"}), + createChange({fileName: "middle", creation: "2019-03-03T10:00:00.000Z"}) + ]; + const workspace = createStubWorkspace(changeList, createManifest("1.75.0")); + + await runTask(workspace); + + const bundle = await getWrittenBundle(workspace); + t.deepEqual( + bundle.changes.map((c) => c.fileName), + ["earliest", "middle", "later"], + "Changes are sorted by ascending creation timestamp" + ); }); -test("task does not fail when manifest.json is missing and no changes exist", async (t) => { - const placeholderWorkspace = { - byGlob: async () => [], // No changes - byPath: async (path) => { - // Return null for all paths (no manifest.json, no flexibility-bundle.json) - return null; - }, - write: sinon.stub().returnsArg(0) +test("mergeFlexChangeBundles merges all bundle categories from existing content", async (t) => { + const existingFlexBundle = { + annotationChanges: [{fileName: "existingAnnotation", fileType: "annotation_change"}], + changes: [{fileName: "existingChange", fileType: "change"}], + compVariants: [{fileName: "existingCompVariant", fileType: "variant"}], + variants: [{fileName: "existingVariant", fileType: "ctrl_variant"}], + variantChanges: [{fileName: "existingVariantChange", fileType: "ctrl_variant_change"}], + variantDependentControlChanges: [ + {fileName: "existingVdc", fileType: "change", variantReference: "abc"} + ], + variantManagementChanges: [ + {fileName: "existingVmc", fileType: "ctrl_variant_management_change"} + ] }; - // This should not throw an error - await t.notThrowsAsync(async () => { - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, - taskUtil: false, - options: { - projectNamespace: "sap/ui/demo/app" - } - }); - }, "Task should not fail when manifest.json is missing"); + const changeList = [ + createChange({fileName: "newAnnotation", fileType: "annotation_change"}), + createChange({fileName: "newChange"}), + createChange({fileName: "newVariant", fileType: "variant"}), + createChange({fileName: "newCtrlVariant", fileType: "ctrl_variant"}), + createChange({fileName: "newCtrlVariantChange", fileType: "ctrl_variant_change"}), + createChange({fileName: "newVdc", variantReference: "abc"}), + createChange({fileName: "newVmc", fileType: "ctrl_variant_management_change"}) + ]; + + const workspace = createStubWorkspace(changeList, createManifest("1.75.0"), {existingFlexBundle}); + + await runTask(workspace); + + const bundle = await getWrittenBundle(workspace); + t.deepEqual(bundle.annotationChanges.map((c) => c.fileName), ["existingAnnotation", "newAnnotation"]); + t.deepEqual(bundle.changes.map((c) => c.fileName), ["existingChange", "newChange"]); + t.deepEqual(bundle.compVariants.map((c) => c.fileName), ["existingCompVariant", "newVariant"]); + t.deepEqual(bundle.variants.map((c) => c.fileName), ["existingVariant", "newCtrlVariant"]); + t.deepEqual(bundle.variantChanges.map((c) => c.fileName), + ["existingVariantChange", "newCtrlVariantChange"]); + t.deepEqual(bundle.variantDependentControlChanges.map((c) => c.fileName), + ["existingVdc", "newVdc"]); + t.deepEqual(bundle.variantManagementChanges.map((c) => c.fileName), + ["existingVmc", "newVmc"]); +}); - // No write calls should have been made since there's no manifest and no changes - t.is(placeholderWorkspace.write.callCount, 0, "workspace.write should not be called when manifest is missing"); +test("mergeFlexChangeBundles keeps the new content when existing category is not an array", async (t) => { + // Existing bundle contains a non-array value for a category — should be replaced by the new content + const existingFlexBundle = {changes: "notAnArray", otherKey: "shouldBeIgnored"}; + const workspace = createStubWorkspace( + [createChange({fileName: "newChange"})], + createManifest("1.75.0"), + {existingFlexBundle} + ); + + await runTask(workspace); + + const bundle = await getWrittenBundle(workspace); + t.deepEqual(bundle.changes.map((c) => c.fileName), ["newChange"], + "When the existing category is not an array, the new content wins"); + t.false("otherKey" in bundle, "Only keys from the new bundle format are kept"); }); -test("task does not fail when manifest.json is missing but changes exist", async (t) => { - const changeList = [{ - "fileName": "test_change", - "fileType": "change", - "changeType": "rename", - "reference": "test.Component", - "content": {}, - "selector": {"id": "testId"}, - "layer": "CUSTOMER" - }]; - - const placeholderWorkspace = { - byGlob: async () => changeList.map((change) => createPlaceholderResource(change)), - byPath: async (path) => { - // Return null for all paths (no manifest.json, no flexibility-bundle.json) - return null; - }, - write: sinon.stub().returnsArg(0) +test("taskUtil.setTag is called with OmitFromBuildResult for every processed resource", async (t) => { + const changeList = [createChange({fileName: "c1"}), createChange({fileName: "c2"})]; + const workspace = createStubWorkspace(changeList, createManifest("1.75.0")); + const taskUtil = { + STANDARD_TAGS: {OmitFromBuildResult: "OmitFromBuildResult"}, + setTag: sinon.stub() }; - // This should not throw an error - await t.notThrowsAsync(async () => { - await generateFlexChangesBundle({ - workspace: placeholderWorkspace, - taskUtil: false, - options: { - projectNamespace: "sap/ui/demo/app" - } - }); - }, "Task should not fail when manifest.json is missing even with changes"); - - // Verify the task created the flexibility-bundle.json - const writeCalls = []; - for (let i = 0; i < placeholderWorkspace.write.callCount; i++) { - const call = placeholderWorkspace.write.getCall(i); - const path = call.args[0].getPath ? await call.args[0].getPath() : "unknown"; - writeCalls.push(path); - } + await runTask(workspace, {taskUtil}); - // Should have written flexibility-bundle.json but NOT manifest.json - t.true(writeCalls.some((path) => path.includes("flexibility-bundle.json")), - "flexibility-bundle.json should be created"); - t.false(writeCalls.some((path) => path.includes("manifest.json")), - "No manifest.json write should occur when manifest is missing"); + t.is(taskUtil.setTag.callCount, changeList.length, + "setTag is called once per input resource"); + for (let i = 0; i < taskUtil.setTag.callCount; i++) { + t.is(taskUtil.setTag.getCall(i).args[1], "OmitFromBuildResult", + "setTag is called with the OmitFromBuildResult tag"); + } }); + +test("mixed minUI5Version array where one version is below 1.73 falls back to changes-bundle.json", + async (t) => { + const workspace = createStubWorkspace( + [createChange()], + createManifest(["1.72.0", "2.0.0"]) + ); + + await runTask(workspace); + + const paths = await getWrittenPaths(workspace); + t.true(paths.some((p) => p.endsWith("/changes/changes-bundle.json")), + "changes-bundle.json is created when at least one minUI5Version entry is below 1.73"); + t.false(paths.some((p) => p.endsWith("/changes/flexibility-bundle.json")), + "flexibility-bundle.json is not created in this case"); + });