diff --git a/packages/logger/lib/loggers/ProjectBuild.js b/packages/logger/lib/loggers/ProjectBuild.js index 57856e211fe..84bbdee604d 100644 --- a/packages/logger/lib/loggers/ProjectBuild.js +++ b/packages/logger/lib/loggers/ProjectBuild.js @@ -67,7 +67,7 @@ class ProjectBuild extends Logger { } } - endTask(taskName, isDifferentialBuild) { + endTask(taskName, isDifferentialBuild, writtenResourcePaths) { if (!this.#tasksToRun || !this.#tasksToRun.includes(taskName)) { throw new Error(`loggers/ProjectBuild#endTask: Unknown task ${taskName}`); } @@ -79,6 +79,7 @@ class ProjectBuild extends Logger { taskName, status: "task-end", isDifferentialBuild, + writtenResourcePaths, }); if (!hasListeners) { diff --git a/packages/logger/test/lib/loggers/ProjectBuild.js b/packages/logger/test/lib/loggers/ProjectBuild.js index 446732d41fe..9e5cb639f96 100644 --- a/packages/logger/test/lib/loggers/ProjectBuild.js +++ b/packages/logger/test/lib/loggers/ProjectBuild.js @@ -137,6 +137,7 @@ test.serial("End task", (t) => { status: "task-end", taskName: "task.a", isDifferentialBuild: undefined, + writtenResourcePaths: undefined, }, "Metadata event has expected payload"); t.is(logHandler.callCount, 0, "No log event emitted"); @@ -144,6 +145,28 @@ test.serial("End task", (t) => { t.is(logStub.callCount, 0, "_log was never called"); }); +test.serial("End task with written resource paths", (t) => { + const {projectBuildLogger, logHandler, metadataHandler, statusHandler, logStub} = t.context; + projectBuildLogger.setTasks(["task.a"]); + + projectBuildLogger.endTask("task.a", true, ["/resources/a.js", "/resources/b.js"]); + + t.is(statusHandler.callCount, 1, "One build-status event emitted"); + t.deepEqual(statusHandler.getCall(0).args[0], { + level: "verbose", + projectName: "projectName", + projectType: "projectType", + status: "task-end", + taskName: "task.a", + isDifferentialBuild: true, + writtenResourcePaths: ["/resources/a.js", "/resources/b.js"], + }, "Metadata event carries differential flag and written resource paths"); + + t.is(logHandler.callCount, 0, "No log event emitted"); + t.is(metadataHandler.callCount, 1, "One build-metadata event emitted"); + t.is(logStub.callCount, 0, "_log was never called"); +}); + test.serial("No event listener: Start task", (t) => { const {projectBuildLogger, logHandler, metadataHandler, statusHandler, logStub} = t.context; process.off(ProjectBuildLogger.PROJECT_BUILD_STATUS_EVENT_NAME, statusHandler); diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 0e6ed4befe2..763743bf993 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -238,12 +238,12 @@ class TaskRunner { this._log.perf( `Task ${taskName} finished in ${Math.round((performance.now() - this._taskStart))} ms`); } - this._log.endTask(taskName); - await this._buildCache.recordTaskResult(taskName, + const writtenResourcePaths = await this._buildCache.recordTaskResult(taskName, workspace.getResourceRequests(), dependencies?.getResourceRequests(), usingCache ? cacheInfo : undefined, supportsDifferentialBuilds); + this._log.endTask(taskName, usingCache, writtenResourcePaths); }; } this._tasks[taskName] = { @@ -486,12 +486,12 @@ class TaskRunner { } this._log.startTask(taskName, usingCache); await taskFunction(params); - this._log.endTask(taskName); - await this._buildCache.recordTaskResult(taskName, + const writtenResourcePaths = await this._buildCache.recordTaskResult(taskName, workspace.getResourceRequests(), dependencies?.getResourceRequests(), usingCache ? cacheInfo : undefined, supportsDifferentialBuilds); + this._log.endTask(taskName, usingCache, writtenResourcePaths); }; } diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index b1941e0d37e..7f9842da87e 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -841,7 +841,8 @@ export default class ProjectBuildCache { * Resource requests for dependency resources * @param {object} cacheInfo Cache information for differential updates * @param {boolean} supportsDifferentialBuilds Whether the task supports differential updates - * @returns {Promise} + * @returns {Promise} The resource paths written by the task, + * or undefined if caching is disabled */ async recordTaskResult( taskName, projectResourceRequests, dependencyResourceRequests, cacheInfo, supportsDifferentialBuilds @@ -980,6 +981,7 @@ export default class ProjectBuildCache { `completed in ${(performance.now() - recordStart).toFixed(2)} ms ` + `(${writtenResourcePaths.length} written resources, delta=${!!cacheInfo})`); } + return writtenResourcePaths; } /** diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index dff935f0115..1588bc9248d 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -1426,6 +1426,81 @@ test.serial.failing( "Served debug source map no longer reflects the stale input source map content"); }); +// CPOUI5FOUNDATION-1363 (cross-project theme `@import` regression guard): buildThemes resolves LESS +// `@import`s through its workspace+dependencies combo (fsInterface(combo) in buildThemes.js). When a +// theme-library's `library.source.less` `@import`s the base theme LESS of a *different* control +// library, that `@import` is a cross-project DEPENDENCY read. Changing the imported base LESS while the +// server runs must re-run the theme-library's buildThemes and serve fresh CSS — the theme-library +// "builds on top of" the base theme, so a base-theme change must propagate. This test asserts that: +// build the theme-library's `library.css` (which embeds the base color pulled in via the cross-project +// `@import`), change ONLY the base library's `themes/base/library.source.less`, notify the watcher, and +// expect the served CSS to reflect the new base color WITHOUT a server restart. +// +// This scenario passes on main and guards the current cross-project `@import` invalidation behavior +// against regression while the new task system (CPOUI5FOUNDATION-1363) is developed on a separate +// branch, where the same behavior must be preserved by design rather than by chance. +test.serial( + "Serve theme.library.e, changing an @import-ed base theme LESS in a dependency invalidates the theme CSS", + async (t) => { + const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "theme.library.e"); + + // Wire up a base control library dependency that ships a base theme `library.source.less`, and + // make theme.library.e's theme `@import` it across the project boundary. Done before serveProject + // so the file watcher does not race with these writes (see FixtureTester.create's note). + const baseLibDir = `${fixtureTester.fixturePath}/node_modules/library.base`; + const baseThemeDir = `${baseLibDir}/src/library/base/themes/base`; + const baseLessPath = `${baseThemeDir}/library.source.less`; + await fs.mkdir(baseThemeDir, {recursive: true}); + await fs.writeFile(baseLessPath, + `@baseColor: #010101;\n.baseRule {\n\tcolor: @baseColor;\n}\n`); + await fs.writeFile(`${baseLibDir}/src/library/base/.library`, + `\n` + + `\n` + + `\tlibrary.base\n\tme\n\t1.0.0\n` + + `\tBase library\n\n`); + // specVersion 2.3 (like the library.a fixture) so no manifest.json is required in source. + await fs.writeFile(`${baseLibDir}/ui5.yaml`, + `---\nspecVersion: "2.3"\ntype: library\nmetadata:\n name: library.base\n`); + await fs.writeFile(`${baseLibDir}/package.json`, + `{\n\t"name": "library.base",\n\t"version": "1.0.0"\n}\n`); + + // Declare the dependency and rewrite the theme LESS to import the base library's base theme. + const pkgPath = `${fixtureTester.fixturePath}/package.json`; + const pkg = JSON.parse(await fs.readFile(pkgPath, {encoding: "utf8"})); + pkg.dependencies = {...(pkg.dependencies || {}), "library.base": "file:./node_modules/library.base"}; + await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2)); + + const themeLessPath = + `${fixtureTester.fixturePath}/src/theme/library/e/themes/my_theme/library.source.less`; + await fs.writeFile(themeLessPath, + `@import "/resources/library/base/themes/base/library.source.less";\n\n` + + `.sapUiBody {\n\tbackground-color: @baseColor;\n}\n`); + + await fixtureTester.serveProject(); + + const cssResource = "/resources/theme/library/e/themes/my_theme/library.css"; + + // #1 request builds the theme; the compiled CSS embeds the base color imported from library.base. + const first = await fixtureTester.requestResource({resource: cssResource}); + const firstContent = await first.getString(); + t.true(firstContent.includes("#010101"), + "Initial theme CSS reflects the base color imported from the base library's base theme"); + + // Change ONLY the base library's base theme LESS — the theme-library's own source is untouched. + await fs.writeFile(baseLessPath, + `@baseColor: #020202;\n.baseRule {\n\tcolor: @baseColor;\n}\n`); + await fixtureTester.fireWatcherEvent("update", baseLessPath); + + // #2 request: the served theme CSS must reflect the changed base color, because the theme + // `@import`s the base library's base theme and thus builds on top of it. + const second = await fixtureTester.requestResource({resource: cssResource}); + const secondContent = await second.getString(); + t.true(secondContent.includes("#020202"), + "Served theme CSS reflects the changed @import-ed base theme LESS without a server restart"); + t.false(secondContent.includes("#010101"), + "Served theme CSS no longer reflects the stale base color"); + }); + function getFixturePath(fixtureName) { return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url)); } diff --git a/packages/project/test/lib/build/ProjectBuilder.caching.integration.js b/packages/project/test/lib/build/ProjectBuilder.caching.integration.js index afad3d0d54d..eb80ef1f5c4 100644 --- a/packages/project/test/lib/build/ProjectBuilder.caching.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.caching.integration.js @@ -5,6 +5,167 @@ import {createFixtureTesterFactory, registerBuildHooks} from "./__helper__/Proje const FixtureTester = createFixtureTesterFactory("caching"); registerBuildHooks(test); +// The three output resources buildThemes writes per theme (see themeBuilder.js): the compiled CSS, +// its RTL variant and the extracted theme parameters. +function themeOutputs(namespace) { + const base = `/resources/${namespace}/themes/my_theme`; + return [ + `${base}/library.css`, + `${base}/library-RTL.css`, + `${base}/library-parameters.json`, + ]; +} + +// buildThemes builds a library's theme only if a `library.js`/`.library` marker for that library is +// available via workspace+dependencies (its `librariesPattern` filter, active when the theme-library +// is built as a DEPENDENCY). The `themelib.multi` fixture ships `library.source.less` for two library +// namespaces (`lib/one`, `lib/two`), each gated by its own `library.js` marker. Adding/removing a +// marker changes which single theme should be (re)built — the others must stay served from cache. +// +// Both tests are marked test.serial.failing: buildThemes does NOT set `supportsDifferentialBuilds`, +// so ANY tracked-input change re-runs the whole task and rewrites EVERY matched theme. There is no +// per-theme delta and no preservation of unaffected theme output. The new task system +// (CPOUI5FOUNDATION-1363) is expected to make this correct by design; dropping `.failing` once that +// work lands will show the gap is closed. The assertions below state the DESIRED behavior. + +test.serial.failing( + "buildThemes: adding a library rebuilds only the newly enabled theme, others stay cached", + async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // Materialize the fixture with an initial build (addMultiLibraryThemeLibraryDependency must run + // AFTER the fixture is copied, as the first buildProject re-initializes the fixture directory). + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + }); + + // Add the multi-library theme-library, initially WITHOUT the lib/two marker: only lib/one's + // theme is enabled by the librariesPattern filter. + await fixtureTester.addMultiLibraryThemeLibraryDependency( + `${fixtureTester.fixturePath}/webapp`, {libTwoMarker: false}); + + // #1 build (fills the cache): buildThemes builds ONLY lib/one's theme. + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "themelib.multi": { + writtenResources: { + buildThemes: themeOutputs("lib/one"), + }, + }, + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "generateVersionInfo", + "replaceCopyright", + ], + }, + }, + }, + }); + + // Add the lib/two marker: its theme now becomes eligible. Only lib/two's theme is new work; + // lib/one's already-built theme output is unaffected and should be reused from cache. + await fixtureTester.setMultiLibraryThemeLibTwoMarker(true); + + // #2 build (with cache, with changes): DESIRED — buildThemes writes ONLY lib/two's theme. + // Fails today: the whole task re-runs and rewrites lib/one's theme too (6 files instead of 3). + // (Only themelib.multi is rebuilt here; application.a is fully served from cache.) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "themelib.multi": { + skippedTasks: ["replaceCopyright", "replaceVersion"], + writtenResources: { + buildThemes: themeOutputs("lib/two"), + }, + }, + }, + }, + }); + + // Both themes must be present in the dest regardless of the delta. + for (const outPath of [...themeOutputs("lib/one"), ...themeOutputs("lib/two")]) { + await t.notThrowsAsync(fs.readFile(`${destPath}${outPath}`, {encoding: "utf8"}), + `Built dest contains ${outPath}`); + } + }); + +test.serial.failing( + "buildThemes: removing a library removes only its theme, others stay cached", + async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // Materialize the fixture with an initial build. + await fixtureTester.buildProject({ + config: {destPath, cleanDest: false, dependencyIncludes: {includeAllDependencies: true}}, + }); + + // Add the multi-library theme-library with BOTH markers present: both themes are built. + await fixtureTester.addMultiLibraryThemeLibraryDependency(`${fixtureTester.fixturePath}/webapp`); + + // #1 build (fills the cache): buildThemes builds both lib/one's and lib/two's theme. + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "themelib.multi": { + writtenResources: { + buildThemes: [...themeOutputs("lib/one"), ...themeOutputs("lib/two")], + }, + }, + "application.a": { + skippedTasks: [ + "enhanceManifest", + "escapeNonAsciiCharacters", + "generateFlexChangesBundle", + "generateVersionInfo", + "replaceCopyright", + ], + }, + }, + }, + }); + + // Remove the lib/two marker: lib/two's theme is no longer eligible and its output must be + // removed. lib/one's theme is unaffected and should be reused from cache (not rewritten). + await fixtureTester.setMultiLibraryThemeLibTwoMarker(false); + + // #2 build (with cache, with changes): DESIRED — buildThemes does NOT rewrite lib/one's theme + // (empty written set for buildThemes; the survivor is carried forward from cache). + // Fails today: the whole task re-runs and rewrites lib/one's theme (3 files instead of 0). + // (Only themelib.multi is rebuilt here; application.a is fully served from cache.) + await fixtureTester.buildProject({ + config: {destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true}}, + assertions: { + projects: { + "themelib.multi": { + skippedTasks: ["replaceCopyright", "replaceVersion"], + writtenResources: { + buildThemes: [], + }, + }, + }, + }, + }); + + // lib/one's theme must still be present; lib/two's theme output must be gone. + for (const outPath of themeOutputs("lib/one")) { + await t.notThrowsAsync(fs.readFile(`${destPath}${outPath}`, {encoding: "utf8"}), + `Built dest still contains ${outPath}`); + } + for (const outPath of themeOutputs("lib/two")) { + await t.throwsAsync(fs.readFile(`${destPath}${outPath}`, {encoding: "utf8"}), + undefined, `Built dest no longer contains ${outPath}`); + } + }); + test.serial("Build application.a project multiple times", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; diff --git a/packages/project/test/lib/build/__helper__/ProjectBuilderFixtureTester.js b/packages/project/test/lib/build/__helper__/ProjectBuilderFixtureTester.js index ca3a3caaaf8..dc41f4e7901 100644 --- a/packages/project/test/lib/build/__helper__/ProjectBuilderFixtureTester.js +++ b/packages/project/test/lib/build/__helper__/ProjectBuilderFixtureTester.js @@ -119,6 +119,9 @@ class FixtureTester { * projects: { * "projectName": { * skippedTasks: ["task1", "task2"], + * writtenResources: { + * "taskName": ["/resources/path/a", "/resources/path/b"], + * }, * }, * // ... * }, @@ -127,6 +130,12 @@ class FixtureTester { * * projects - for asserting all projects which are expected to be built * allProjects - optional, for asserting all seen projects nonetheless if built or not + * + * writtenResources - optional per project, asserts the exact set of resource paths a task + * wrote (sourced from the `writtenResourcePaths` field of the `task-end` build-status + * event). Only tasks listed are asserted; other tasks are ignored. This is the signal for + * delta-build correctness: it reveals WHAT a task did (which outputs it (re-)wrote), not + * just whether it ran. */ const {projects = {}, allProjects = []} = assertions; @@ -149,12 +158,15 @@ class FixtureTester { const projectBuildStatusEvents = this._t.context.projectBuildStatusEventStub.args.map((args) => args[0]); for (const event of projectBuildStatusEvents) { if (!tasksByProject[event.projectName]) { - tasksByProject[event.projectName] = {executed: [], skipped: []}; + tasksByProject[event.projectName] = {executed: [], skipped: [], writtenResources: {}}; } if (event.status === "task-skip") { tasksByProject[event.projectName].skipped.push(event.taskName); } else if (event.status === "task-start") { tasksByProject[event.projectName].executed.push(event.taskName); + } else if (event.status === "task-end") { + tasksByProject[event.projectName].writtenResources[event.taskName] = + event.writtenResourcePaths; } } @@ -170,12 +182,23 @@ class FixtureTester { "All seen projects (built or not) should match expected"); } - // Assert skipped tasks per project - for (const [projectName, expectedSkipped] of Object.entries(projects)) { - const skippedTasks = expectedSkipped.skippedTasks || []; + // Assert skipped tasks and written resources per project + for (const [projectName, expected] of Object.entries(projects)) { + const skippedTasks = expected.skippedTasks || []; const actualSkipped = (tasksByProject[projectName]?.skipped || []).sort(); const expectedArray = skippedTasks.sort(); this._t.deepEqual(actualSkipped, expectedArray); + + if (expected.writtenResources) { + const actualWritten = tasksByProject[projectName]?.writtenResources || {}; + for (const [taskName, expectedPaths] of Object.entries(expected.writtenResources)) { + this._t.deepEqual( + [...(actualWritten[taskName] || [])].sort(), + [...expectedPaths].sort(), + `Written resources of task '${taskName}' in project '${projectName}' should match expected` + ); + } + } } } @@ -221,42 +244,50 @@ resources: /** * Adds a minimal `sap.ui.core` dependency to an arbitrary root project, at a controllable version. * - * It is declared as a `type: module`: it only needs to exist in the graph and expose a version via - * `taskUtil.getProject("sap.ui.core").getVersion()`. A `type: module` runs no build tasks and needs - * no library scaffolding to build cleanly. + * It is declared as a normal `type: library`: it only needs to exist in the graph and expose a + * version via `taskUtil.getProject("sap.ui.core").getVersion()`. `generateLibraryPreload` reads only + * the current project's own workspace (never the `dependencies` reader), so the core's built resource + * content is not an input to a depender's preload — the version reaches the output solely through + * `getProject("sap.ui.core").getVersion()`. * * Ships `/resources/ui5loader.js` and `/resources/sap/ui/core/Core.js` so a bundle definition with a - * `require`/`preload` section filtering `sap/ui/core/Core.js` resolves. + * `require`/`preload` section filtering `sap/ui/core/Core.js` resolves, plus the `.library` file a + * library project requires. * * @param {string} [version="1.120.0"] Initial `package.json` version of the dependency */ async addSapUiCoreDependency(version = "1.120.0") { - const modulePath = `${this.fixturePath}/node_modules/sap.ui.core`; - await fs.mkdir(`${modulePath}/main/src/sap/ui/core`, {recursive: true}); - await fs.writeFile(`${modulePath}/main/src/ui5loader.js`, + const modulePath = `${this.fixturePath}/node_modules/@openui5/sap.ui.core`; + await fs.mkdir(`${modulePath}/src/sap/ui/core`, {recursive: true}); + await fs.writeFile(`${modulePath}/src/ui5loader.js`, `(function () {\n\tvar thisIsTheUi5Loader = true;\n\tconsole.log(thisIsTheUi5Loader);\n})()\n`); - await fs.writeFile(`${modulePath}/main/src/sap/ui/core/Core.js`, + await fs.writeFile(`${modulePath}/src/sap/ui/core/Core.js`, `sap.ui.define([], function() {\n\t"use strict";\n\treturn {};\n});\n`); + await fs.writeFile(`${modulePath}/src/sap/ui/core/.library`, + `\n` + + `\n` + + `\tsap.ui.core\n` + + `\tSAP SE\n` + + `\tSome fancy copyright\n` + + `\t${version}\n` + + `\tSAP UI core library\n` + + `\n`); await fs.writeFile(`${modulePath}/ui5.yaml`, `--- specVersion: "5.0" -type: module +type: library metadata: name: sap.ui.core -resources: - configuration: - paths: - /resources/: main/src `); await fs.writeFile(`${modulePath}/package.json`, - JSON.stringify({name: "sap.ui.core", version}, null, "\t")); + JSON.stringify({name: "@openui5/sap.ui.core", version}, null, "\t")); const packageJsonContent = JSON.parse( await fs.readFile(`${this.fixturePath}/package.json`, {encoding: "utf8"})); if (!packageJsonContent.dependencies) { packageJsonContent.dependencies = {}; } - packageJsonContent.dependencies["sap.ui.core"] = "file:./node_modules/sap.ui.core"; + packageJsonContent.dependencies["@openui5/sap.ui.core"] = "file:./node_modules/@openui5/sap.ui.core"; await fs.writeFile(`${this.fixturePath}/package.json`, JSON.stringify(packageJsonContent) ); @@ -264,8 +295,9 @@ resources: /** * Changes the `package.json` version of the "sap.ui.core" dependency created by - * {@link addSapUiCoreDependency}. Since that dependency is a `type: module` (no build tasks), this - * changes ONLY the project version metadata — no dependency resource content changes. + * {@link addSapUiCoreDependency}. That `package.json` version is what + * `taskUtil.getProject("sap.ui.core").getVersion()` returns, which is the only channel through which + * the core version reaches a depender's `generateLibraryPreload` output. * * @param {string} version The new `package.json` version (e.g. "2.0.0") */ @@ -464,4 +496,105 @@ metadata: JSON.stringify(packageJsonContent) ); } + + /** + * Helper function to add a multi-library theme-library dependency ("themelib.multi") to a root project. + * + * Unlike {@link addThemeLibraryDependency}, this theme-library ships `library.source.less` for TWO + * separate library namespaces (`lib/one` and `lib/two`) and gates each theme with a sibling + * `library.js` marker file placed under the owning library's namespace directory in the + * theme-library's OWN src tree. When the theme-library is built as a DEPENDENCY + * (`isRootProject() === false`), buildThemes' `librariesPattern` + * (`/resources/**/(*.library|library.js)`) then filters which themes are built by the presence + * of these markers (see packages/builder/lib/tasks/buildThemes.js `isAvailable`). + * + * By default both markers are present. Pass `{libTwoMarker: false}` to omit `lib/two`'s + * `library.js` — its theme is then filtered out. The `lib/one` marker is always written so + * `availableLibraries` is never empty (which would trigger the "build everything" escape hatch in + * buildThemes). No `sap/ui/core/themes/*` is shipped, so the `themesPattern` theme-name filter is + * bypassed and only the `librariesPattern` library filter is active. + * + * @param {string} sourceDir - source path of the root project (e.g. `${this.fixturePath}/webapp`) + * @param {object} [options] + * @param {boolean} [options.libTwoMarker=true] Whether to write the `lib/two/library.js` marker + */ + async addMultiLibraryThemeLibraryDependency(sourceDir, {libTwoMarker = true} = {}) { + const modulePath = `${this.fixturePath}/node_modules/themelib.multi`; + + const writeThemeSource = async (namespace) => { + const themeDir = `${modulePath}/src/${namespace}/themes/my_theme`; + await fs.mkdir(themeDir, {recursive: true}); + await fs.writeFile(`${themeDir}/library.source.less`, + `@mycolor: blue; +.sapUiBody { + background-color: @mycolor; +}`); + await fs.writeFile(`${themeDir}/.theme`, + ` + + my_theme + me + ` +"\"${copyright}\"" + ` + ` +"\"${version}\"" + ` +`); + }; + + // A minimal `library.js` marker file for a given namespace. Its mere existence (matching + // librariesPattern) is what enables the corresponding theme to be built. + const writeLibraryMarker = async (namespace) => { + await fs.writeFile(`${modulePath}/src/${namespace}/library.js`, + `sap.ui.define([], () => {});\n`); + }; + + await writeThemeSource("lib/one"); + await writeThemeSource("lib/two"); + await writeLibraryMarker("lib/one"); + if (libTwoMarker) { + await writeLibraryMarker("lib/two"); + } + + await fs.writeFile(`${modulePath}/ui5.yaml`, + `--- +specVersion: "5.0" +type: theme-library +metadata: + name: themelib.multi +`); + await fs.writeFile(`${modulePath}/package.json`, + `{ + "name": "themelib.multi", + "version": "1.0.0" +}` + ); + + await fs.writeFile(`${sourceDir}/themelibMultiConsumer.js`, + `sap.ui.define(["sap/ui/core/Theming"], (Theming) => { + Theming.setTheme("my_theme"); + console.log(Theming.getTheme()); +});`); + const packageJsonContent = JSON.parse( + await fs.readFile(`${this.fixturePath}/package.json`, {encoding: "utf8"})); + if (!packageJsonContent.dependencies) { + packageJsonContent.dependencies = {}; + } + packageJsonContent.dependencies["themelib.multi"] = "file:../themelib.multi"; + await fs.writeFile(`${this.fixturePath}/package.json`, + JSON.stringify(packageJsonContent) + ); + } + + /** + * Adds or removes the `lib/two/library.js` marker of the "themelib.multi" dependency created by + * {@link addMultiLibraryThemeLibraryDependency}, to toggle whether buildThemes builds `lib/two`'s theme. + * + * @param {boolean} present Whether the `lib/two/library.js` marker should exist afterwards + */ + async setMultiLibraryThemeLibTwoMarker(present) { + const markerPath = `${this.fixturePath}/node_modules/themelib.multi/src/lib/two/library.js`; + if (present) { + await fs.writeFile(markerPath, `sap.ui.define([], () => {});\n`); + } else { + await fs.rm(markerPath, {force: true}); + } + } }