Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/logger/lib/loggers/ProjectBuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand All @@ -79,6 +79,7 @@ class ProjectBuild extends Logger {
taskName,
status: "task-end",
isDifferentialBuild,
writtenResourcePaths,
});

if (!hasListeners) {
Expand Down
23 changes: 23 additions & 0 deletions packages/logger/test/lib/loggers/ProjectBuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,36 @@ 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");
t.is(metadataHandler.callCount, 1, "One build-metadata event emitted");
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);
Expand Down
8 changes: 4 additions & 4 deletions packages/project/lib/build/TaskRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down Expand Up @@ -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);
};
}

Expand Down
4 changes: 3 additions & 1 deletion packages/project/lib/build/cache/ProjectBuildCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
* @returns {Promise<string[]|undefined>} The resource paths written by the task,
* or <code>undefined</code> if caching is disabled
*/
async recordTaskResult(
taskName, projectResourceRequests, dependencyResourceRequests, cacheInfo, supportsDifferentialBuilds
Expand Down Expand Up @@ -980,6 +981,7 @@ export default class ProjectBuildCache {
`completed in ${(performance.now() - recordStart).toFixed(2)} ms ` +
`(${writtenResourcePaths.length} written resources, delta=${!!cacheInfo})`);
}
return writtenResourcePaths;
}

/**
Expand Down
75 changes: 75 additions & 0 deletions packages/project/test/lib/build/BuildServer.integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
`<?xml version="1.0" encoding="UTF-8" ?>\n` +
`<library xmlns="http://www.sap.com/sap.ui.library.xsd">\n` +
`\t<name>library.base</name>\n\t<vendor>me</vendor>\n\t<version>1.0.0</version>\n` +
`\t<documentation>Base library</documentation>\n</library>\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));
}
Expand Down
161 changes: 161 additions & 0 deletions packages/project/test/lib/build/ProjectBuilder.caching.integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
matz3 marked this conversation as resolved.
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;
Expand Down
Loading
Loading