From ff2e6237d4a5394e3815fb98c1b0c604d5474b37 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Oct 2026 15:18:23 +0100 Subject: [PATCH 1/3] refactor(project): Clear build tags at project completion Cached tag operations can remain pending when a project finishes. A later read then replays its build tags. Output filtering can also leave a monitor referencing the build tag store after the store is cleared. Apply pending operations before clearing both the build store and its monitor. Keep project tags available to dependents and retain both result-stage monitor resets so resource snapshots expose final tags. Reset all stores and monitors when initializing stages for another build. Keep build tags separate from build-manifest storage. Sharing that object with project tags allows build tags to alter manifest contents. Add regressions for live and cached completion, output filtering, repeated builds, final tag snapshots, and manifest isolation. --- .../project/lib/resources/ProjectResources.js | 17 ++- .../createBuildManifest.integration.js | 7 ++ .../test/lib/resources/ProjectResources.js | 107 +++++++++++++++++- 3 files changed, 118 insertions(+), 13 deletions(-) diff --git a/packages/project/lib/resources/ProjectResources.js b/packages/project/lib/resources/ProjectResources.js index 292c1906305..6b806167c87 100644 --- a/packages/project/lib/resources/ProjectResources.js +++ b/packages/project/lib/resources/ProjectResources.js @@ -247,6 +247,9 @@ class ProjectResources { this.#currentStageWorkspace = null; this.#frozenSourceReader = null; this.#projectResourceTagCollection = null; + this.#buildResourceTagCollection = null; + this.#monitoredProjectResourceTagCollection = null; + this.#monitoredBuildResourceTagCollection = null; } #addReaderForStage(stage, readers, style = "buildtime") { @@ -283,7 +286,7 @@ class ProjectResources { * * Used to recover from a build that threw mid-execution. Such a build leaves the * current stage pointed at the failing task's writer (holding partial output) and - * the tag collections populated, none of which a later build clears on its own. + * the tag collections populated. * After this call the next {@link #getStage} reports the initial stage, * so the build cache re-initializes stages and re-imports cached results. * @@ -291,11 +294,6 @@ class ProjectResources { */ reset() { this.#initStageMetadata(); - // #initStageMetadata resets the project tag collection and stage pipeline but not - // the build-level or monitored collections, which a failed build may have populated. - this.#buildResourceTagCollection = null; - this.#monitoredProjectResourceTagCollection = null; - this.#monitoredBuildResourceTagCollection = null; } /** @@ -422,8 +420,10 @@ class ProjectResources { } buildFinished() { - // Clear build resource tag collections. They must not be provided to dependent projects + // Apply pending result tags before cleanup so later reads cannot replay build tags. + this.#applyCachedResourceTags(); this.#buildResourceTagCollection = null; + this.#monitoredBuildResourceTagCollection = null; } /** @@ -567,8 +567,7 @@ class ProjectResources { if (!this.#buildResourceTagCollection) { this.#buildResourceTagCollection = new ResourceTagCollection({ allowedTags: ["ui5:OmitFromBuildResult", "ui5:IsBundle"], - allowedNamespaces: ["build"], - tags: this.#buildManifest?.tags + allowedNamespaces: ["build"] }); } return this.#buildResourceTagCollection; diff --git a/packages/project/test/lib/build/helpers/createBuildManifest.integration.js b/packages/project/test/lib/build/helpers/createBuildManifest.integration.js index c6b49792b52..71fc766764d 100644 --- a/packages/project/test/lib/build/helpers/createBuildManifest.integration.js +++ b/packages/project/test/lib/build/helpers/createBuildManifest.integration.js @@ -73,6 +73,13 @@ test("Create project from application project providing a build manifest", async "Found requested resource in archive project"); t.is(resources[0].getPath(), "/resources/id1/test.js", "Resource has expected path"); + + const expectedTags = structuredClone(metadata.buildManifest.tags); + project.getResourceTagCollection(resources[0], "ui5:OmitFromBuildResult") + .setTag(resources[0], "ui5:OmitFromBuildResult"); + const nextMetadata = await createBuildManifest(project, buildConfig, taskRepository, "zzz"); + t.deepEqual(nextMetadata.buildManifest.tags, expectedTags, "Build manifests retain only project tags"); + t.deepEqual(metadata.buildManifest.tags, expectedTags, "Build tags leave the input manifest unchanged"); }); test("Create project from library project providing a build manifest", async (t) => { diff --git a/packages/project/test/lib/resources/ProjectResources.js b/packages/project/test/lib/resources/ProjectResources.js index 439aea24816..c82ad3a7298 100644 --- a/packages/project/test/lib/resources/ProjectResources.js +++ b/packages/project/test/lib/resources/ProjectResources.js @@ -3,7 +3,11 @@ import sinon from "sinon"; import {createProxy, createResource} from "@ui5/fs/resourceFactory"; import ProjectResources from "../../../lib/resources/ProjectResources.js"; -function createProjectResources({frozenSourceReader} = {}) { +test.afterEach.always(() => { + sinon.restore(); +}); + +function createProjectResources({frozenSourceReader, buildManifest = null} = {}) { const sourceReader = { byGlob: sinon.stub().resolves([]), byPath: sinon.stub().resolves(null), @@ -18,7 +22,7 @@ function createProjectResources({frozenSourceReader} = {}) { applyStyleToReader: sinon.stub().callsFake((reader) => reader), createWriter: sinon.stub().returns(writer), addReadersForWriter: sinon.stub(), - buildManifest: null, + buildManifest, }); if (frozenSourceReader) { pr.setFrozenSourceReader(frozenSourceReader); @@ -26,8 +30,103 @@ function createProjectResources({frozenSourceReader} = {}) { return {pr, sourceReader, writer}; } -test.afterEach.always(() => { - sinon.restore(); +function createTagResource(pr) { + return createResource({ + path: "/resources/test/file.js", + string: "source", + project: {getResourceTagCollection: (resource, tag) => pr.getResourceTagCollection(resource, tag)} + }); +} + +for (const cached of [false, true]) { + for (const readResultTags of [false, true]) { + test(`buildFinished: ${cached ? "cached" : "live"} tags, result lookup=${readResultTags}`, (t) => { + const {pr, writer} = createProjectResources(); + const resource = createTagResource(pr); + const path = resource.getPath(); + pr.initStages(["task/tag"]); + pr.useStage("task/tag"); + if (cached) { + pr.setStage("task/tag", writer, + new Map([[path, new Map([["ui5:HasDebugVariant", true]])]]), + new Map([[path, new Map([["ui5:OmitFromBuildResult", true]])]])); + } else { + pr.getResourceTagCollection(resource, "ui5:HasDebugVariant") + .setTag(resource, "ui5:HasDebugVariant"); + pr.getResourceTagCollection(resource, "ui5:OmitFromBuildResult") + .setTag(resource, "ui5:OmitFromBuildResult"); + } + pr.useResultStage(); + if (readResultTags) { + t.true(pr.getResourceTagCollection(resource, "ui5:OmitFromBuildResult") + .getTag(resource, "ui5:OmitFromBuildResult"), "Output filtering can read build tags"); + } + pr.buildFinished(); + + t.true(resource.getTags()["ui5:HasDebugVariant"], "Resource reads retain project tags"); + t.true(pr.getProjectResourceTagCollection().getTag(resource, "ui5:HasDebugVariant")); + t.is(pr.getResourceTagCollection(resource, "ui5:OmitFromBuildResult") + .getTag(resource, "ui5:OmitFromBuildResult"), undefined, "Build tags stay cleared after reads"); + pr.buildFinished(); + t.is(pr.getResourceTagCollection(resource, "ui5:OmitFromBuildResult") + .getTag(resource, "ui5:OmitFromBuildResult"), undefined, "Completion is idempotent"); + }); + } +} + +test("useResultStage refreshes the project-tag snapshot after the last task", (t) => { + const {pr} = createProjectResources(); + const resource = createTagResource(pr); + pr.initStages(["task/first", "task/last"]); + pr.useStage("task/first"); + pr.getResourceTagCollection(resource, "project:State").setTag(resource, "project:State", "first"); + pr.useStage("task/last"); + const collection = pr.getResourceTagCollection(resource, "project:State"); + collection.setTag(resource, "project:State", "last"); + t.is(resource.getTags()["project:State"], "first", "The task hashes its input tags"); + pr.useResultStage(); + t.is(resource.getTags()["project:State"], "last", "The result exposes the final tags"); +}); + +for (const method of ["initStages", "reset"]) { + test(`${method} discards tag stores and monitors from an unfinished build`, (t) => { + const {pr} = createProjectResources(); + const resource = createTagResource(pr); + pr.getResourceTagCollection(resource, "project:State").setTag(resource, "project:State", "old"); + pr.getResourceTagCollection(resource, "build:State").setTag(resource, "build:State", "old"); + pr[method]([]); + t.is(pr.getResourceTagCollection(resource, "project:State").getTag(resource, "project:State"), undefined); + t.is(pr.getResourceTagCollection(resource, "build:State").getTag(resource, "build:State"), undefined); + t.is(pr.getResourceTagOperations().projectTagOperations.size, 0); + t.is(pr.getResourceTagOperations().buildTagOperations.size, 0); + }); +} + +test("Cached build tags can be replayed in subsequent builds", (t) => { + const {pr, writer} = createProjectResources(); + const resource = createTagResource(pr); + const buildTags = new Map([[resource.getPath(), new Map([["build:State", "cached"]])]]); + for (let build = 0; build < 3; build++) { + pr.initStages(["task/tag"]); + pr.setStage("task/tag", writer, new Map(), buildTags); + pr.useResultStage(); + t.is(pr.getResourceTagCollection(resource, "build:State").getTag(resource, "build:State"), "cached"); + pr.buildFinished(); + t.is(pr.getResourceTagCollection(resource, "build:State").getTag(resource, "build:State"), undefined); + } +}); + +test("Build tags do not share storage with build manifest tags", (t) => { + const tags = {"/resources/test/file.js": {"ui5:HasDebugVariant": true}}; + const {pr} = createProjectResources({buildManifest: {tags}}); + const resource = createTagResource(pr); + pr.getResourceTagCollection(resource, "ui5:OmitFromBuildResult") + .setTag(resource, "ui5:OmitFromBuildResult"); + t.deepEqual(tags, {"/resources/test/file.js": {"ui5:HasDebugVariant": true}}); + t.deepEqual(pr.getProjectResourceTagCollection().getAllTags(), tags); + pr.buildFinished(); + t.is(pr.getResourceTagCollection(resource, "ui5:OmitFromBuildResult") + .getTag(resource, "ui5:OmitFromBuildResult"), undefined); }); test("setFrozenSourceReader: frozen reader is included in getReader chain", (t) => { From d530d49e89d8134e9f63c215d97d2a81ac989777 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Oct 2026 15:18:37 +0100 Subject: [PATCH 2/3] refactor(project): Preserve current tag values during delta replay A step that reruns after a dependency changes can update a tag, then have its value overwritten when recordStageResult imports the previous stage's operations. Later stages see stale values even though the cache stores the correctly merged operations. Import the merged project and build tag operations into the live collections. Current delta values take precedence, while tags unchanged by the delta remain available to subsequent stages. Extend the cache tests to check the values imported for later stages as well as the values written to the persistent cache. --- packages/project/lib/build/cache/ProjectBuildCache.js | 7 ++----- .../project/test/lib/build/cache/ProjectBuildCache.js | 9 +++++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 635ed6257cc..0f95b554f51 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -1245,12 +1245,9 @@ export default class ProjectBuildCache { ]); } - // Import the previous stage cache's tag operations into the tag collections so that - // subsequent stages can access them. Delta builds only record tags set during delta - // execution, so the previous build's tags must be imported explicitly. + // Apply the merged operations so later stages see retained tags and the delta's current values. this.#project.getProjectResources().importTagOperations( - cacheInfo.previousStageCache.projectTagOperations, - cacheInfo.previousStageCache.buildTagOperations); + projectTagOperations, buildTagOperations); stageSignature = cacheInfo.newSignature; // Add resources from previous stage cache to current stage diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index e4b9c0f85a1..3da4863c868 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -618,9 +618,9 @@ test("recordStageResult with cacheInfo: calls importTagOperations with previous const importStub = project.getProjectResources().importTagOperations; t.true(importStub.calledOnce, "importTagOperations called once"); - t.is(importStub.firstCall.args[0], prevProjectTags, + t.deepEqual(importStub.firstCall.args[0], prevProjectTags, "Called with previous stage projectTagOperations"); - t.is(importStub.firstCall.args[1], prevBuildTags, + t.deepEqual(importStub.firstCall.args[1], prevBuildTags, "Called with previous stage buildTagOperations"); }); @@ -664,6 +664,7 @@ test("recordStageResult with cacheInfo: merges tag operations with current delta ]), buildTagOperations: new Map([ ["/a.js", new Map([["ui5:OldBuildTag", "old"]])], + ["/c.js", new Map([["ui5:NewBuildTag", "previous"]])], ]), }, newSignature: "merged-sig", @@ -680,6 +681,10 @@ test("recordStageResult with cacheInfo: merges tag operations with current delta cacheInfo, }); + const [projectTags, buildTags] = project.getProjectResources().importTagOperations.firstCall.args; + t.is(projectTags.get("/a.js").get("ui5:IsDebugVariant"), false, "Later stages see the current project tag"); + t.is(buildTags.get("/c.js").get("ui5:NewBuildTag"), "val", "Later stages see the current build tag"); + // Verify merged tags via writeCache -> cacheManager.writeStageCache await cache.writeCache(); From 4fece12185826e6da9dd464d32bafeb80213069e Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Oct 2026 15:18:52 +0100 Subject: [PATCH 3/3] refactor(project): Scope build tags to the executing project Tasks can tag dependency resources during a build. Routing all tags to the resource owner exposes build tags to other projects and records tag operations against the wrong project's stage cache. Route build tags through the executing project's ProjectResources, matching the v4 build-context scope. Continue to store project tags on the resource owner. Classify tags without replaying cached operations or creating a monitor that would capture a stage snapshot too early. Cover cold, warm-cache, and delta builds with legacy and step-based tasks, plus serving restarts and rebuilds. Verify dependency isolation, project-tag visibility, and output filtering. --- .../lib/build/helpers/ProjectBuildContext.js | 10 +- .../project/lib/build/helpers/TaskUtil.js | 4 +- .../project/lib/resources/ProjectResources.js | 16 ++- .../project/lib/specifications/Project.js | 5 +- .../application.a/tag-isolation/producer.cjs | 13 ++ .../application.a/tag-isolation/reader.cjs | 21 ++++ .../application.a/tag-isolation/setter.cjs | 24 ++++ .../tag-isolation/step-setter.cjs | 9 ++ .../lib/build/BuildServer.tags.integration.js | 38 ++++++ .../build/ProjectBuilder.tags.integration.js | 41 ++++++ .../test/lib/build/__helper__/tagIsolation.js | 46 +++++++ .../lib/build/helpers/ProjectBuildContext.js | 117 ++++++++++-------- .../test/lib/resources/ProjectResources.js | 19 +++ 13 files changed, 296 insertions(+), 67 deletions(-) create mode 100644 packages/project/test/fixtures/application.a/tag-isolation/producer.cjs create mode 100644 packages/project/test/fixtures/application.a/tag-isolation/reader.cjs create mode 100644 packages/project/test/fixtures/application.a/tag-isolation/setter.cjs create mode 100644 packages/project/test/fixtures/application.a/tag-isolation/step-setter.cjs create mode 100644 packages/project/test/lib/build/BuildServer.tags.integration.js create mode 100644 packages/project/test/lib/build/ProjectBuilder.tags.integration.js create mode 100644 packages/project/test/lib/build/__helper__/tagIsolation.js diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index a6a02ab2e08..b7de28a4843 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -252,9 +252,9 @@ class ProjectBuildContext { /** * Gets the appropriate resource tag collection for a resource and tag * - * Determines which tag collection (project-specific or build-level) should be used - * for the given resource and tag combination. Associates the resource with the current - * project if not already associated. + * Project tags belong to the resource's owner. Build tags belong to the executing + * project, including tags on dependency resources. Associates unowned resources + * with the executing project. * * @param {@ui5/fs/Resource} resource Resource to get tag collection for * @param {string} tag Tag to check acceptance for @@ -266,7 +266,9 @@ class ProjectBuildContext { this._log.silly(`Associating resource ${resource.getPath()} with project ${this._project.getName()}`); resource.setProject(this._project); } - return resource.getProject().getResourceTagCollection(resource, tag); + const project = this._project.getProjectResources().acceptsProjectTag(tag) ? + resource.getProject() : this._project; + return project.getResourceTagCollection(resource, tag); } /** diff --git a/packages/project/lib/build/helpers/TaskUtil.js b/packages/project/lib/build/helpers/TaskUtil.js index ea55bb50fc3..d3fbc9dac80 100644 --- a/packages/project/lib/build/helpers/TaskUtil.js +++ b/packages/project/lib/build/helpers/TaskUtil.js @@ -63,8 +63,8 @@ class TaskUtil { HasDebugVariant: "ui5:HasDebugVariant", // "Build" tags: - // Will be stored on the project build context - // They are only available to the build tasks of a single project + // Stored on the executing project's ProjectResources, including tags on dependency resources + // Available to that project's build tasks and output filtering until the build finishes OmitFromBuildResult: "ui5:OmitFromBuildResult", IsBundle: "ui5:IsBundle" }); diff --git a/packages/project/lib/resources/ProjectResources.js b/packages/project/lib/resources/ProjectResources.js index 6b806167c87..f99a2d7d856 100644 --- a/packages/project/lib/resources/ProjectResources.js +++ b/packages/project/lib/resources/ProjectResources.js @@ -426,12 +426,22 @@ class ProjectResources { this.#monitoredBuildResourceTagCollection = null; } + /** + * Check whether a tag belongs to the resource owner's project. + * This check does not import cached tags or create a stage monitor. + * + * @param {string} [tag] Tag to classify. An omitted tag selects project tags. + * @returns {boolean} Whether the tag belongs to the project collection + */ + acceptsProjectTag(tag) { + return !tag || this.#getProjectResourceTagCollection().acceptsTag(tag); + } + /** * Gets the appropriate resource tag collection for a resource and tag * - * Determines which tag collection (project-specific or build-level) should be used - * for the given resource and tag combination. Associates the resource with the current - * project if not already associated. + * Selects this project's collection by tag. The resource can belong to a dependency + * when a task stores build tags for that resource in the executing project's collection. * * @param {@ui5/fs/Resource} resource Resource to get tag collection for * @param {string} tag Tag to check acceptance for diff --git a/packages/project/lib/specifications/Project.js b/packages/project/lib/specifications/Project.js index 15199b61e50..683c4276841 100644 --- a/packages/project/lib/specifications/Project.js +++ b/packages/project/lib/specifications/Project.js @@ -333,9 +333,8 @@ class Project extends Specification { /** * Gets the appropriate resource tag collection for a resource and tag * - * Determines which tag collection (project-specific or build-level) should be used - * for the given resource and tag combination. Associates the resource with the current - * project if not already associated. + * Selects this project's collection by tag. ProjectBuildContext routes project tags + * to the resource's owner and build tags to the executing project. * * @param {@ui5/fs/Resource} resource Resource to get tag collection for * @param {string} tag Tag to check acceptance for diff --git a/packages/project/test/fixtures/application.a/tag-isolation/producer.cjs b/packages/project/test/fixtures/application.a/tag-isolation/producer.cjs new file mode 100644 index 00000000000..9ee12df9b95 --- /dev/null +++ b/packages/project/test/fixtures/application.a/tag-isolation/producer.cjs @@ -0,0 +1,13 @@ +const assert = require("node:assert/strict"); + +module.exports = function build() { + return { + task: async ({workspace, taskUtil}) => { + const resource = await workspace.byPath("/resources/library/d/tag-input.txt"); + assert.ok(resource); + taskUtil.setTag(resource, "project:Producer", await resource.getString()); + taskUtil.setTag(resource, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + taskUtil.setTag(resource, taskUtil.STANDARD_TAGS.IsBundle); + } + }; +}; diff --git a/packages/project/test/fixtures/application.a/tag-isolation/reader.cjs b/packages/project/test/fixtures/application.a/tag-isolation/reader.cjs new file mode 100644 index 00000000000..fc4e7ebef52 --- /dev/null +++ b/packages/project/test/fixtures/application.a/tag-isolation/reader.cjs @@ -0,0 +1,21 @@ +const assert = require("node:assert/strict"); + +module.exports = function build() { + return { + task: async ({workspace, dependencies, taskUtil, options: {projectNamespace}}) => { + const resource = await dependencies.byPath("/resources/library/d/tag-input.txt"); + assert.ok(resource); + const value = await resource.getString(); + assert.equal(taskUtil.getTag(resource, "build:Consumer"), value); + assert.equal(taskUtil.getTag(resource, "project:Producer"), value); + assert.equal(taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.OmitFromBuildResult), undefined); + assert.equal(taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.IsBundle), undefined); + const trigger = await workspace.byPath(`/resources/${projectNamespace}/tag-trigger.txt`); + await workspace.write(taskUtil.resourceFactory.createResource({ + path: `/resources/${projectNamespace}/tag-report.txt`, + string: `${value}:${await trigger.getString()}` + })); + }, + determineRequiredDependencies: ({availableDependencies}) => availableDependencies + }; +}; diff --git a/packages/project/test/fixtures/application.a/tag-isolation/setter.cjs b/packages/project/test/fixtures/application.a/tag-isolation/setter.cjs new file mode 100644 index 00000000000..cda7a5b5632 --- /dev/null +++ b/packages/project/test/fixtures/application.a/tag-isolation/setter.cjs @@ -0,0 +1,24 @@ +const assert = require("node:assert/strict"); + +module.exports = function build() { + return { + task: async ({workspace, dependencies, taskUtil, options: {projectNamespace}}) => { + const resource = await dependencies.byPath("/resources/library/d/tag-input.txt"); + assert.ok(resource); + assert.equal(taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.OmitFromBuildResult), undefined); + assert.equal(taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.IsBundle), undefined); + assert.equal(taskUtil.getTag(resource, "build:Consumer"), undefined); + const value = await resource.getString(); + assert.equal(taskUtil.getTag(resource, "project:Producer"), value); + taskUtil.setTag(resource, "build:Consumer", value); + + const omitted = taskUtil.resourceFactory.createResource({ + path: `/resources/${projectNamespace}/tag-omitted.txt`, + string: value + }); + taskUtil.setTag(omitted, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + await workspace.write(omitted); + }, + determineRequiredDependencies: ({availableDependencies}) => availableDependencies + }; +}; diff --git a/packages/project/test/fixtures/application.a/tag-isolation/step-setter.cjs b/packages/project/test/fixtures/application.a/tag-isolation/step-setter.cjs new file mode 100644 index 00000000000..f89d4a00291 --- /dev/null +++ b/packages/project/test/fixtures/application.a/tag-isolation/step-setter.cjs @@ -0,0 +1,9 @@ +const setter = require("./setter.cjs"); + +module.exports = function build(options) { + const {task, determineRequiredDependencies} = setter(options); + return { + steps: [{name: "tag", run: task}], + determineRequiredDependencies + }; +}; diff --git a/packages/project/test/lib/build/BuildServer.tags.integration.js b/packages/project/test/lib/build/BuildServer.tags.integration.js new file mode 100644 index 00000000000..8bded7799ec --- /dev/null +++ b/packages/project/test/lib/build/BuildServer.tags.integration.js @@ -0,0 +1,38 @@ +import test from "ava"; +import esmock from "esmock"; +import fs from "node:fs/promises"; +import { + createParcelWatcherMock, createFixtureTesterFactory, registerBuildHooks +} from "./__helper__/BuildServerFixtureTester.js"; +import {configureTagIsolation, assertTagTasks} from "./__helper__/tagIsolation.js"; + +const watcherMock = createParcelWatcherMock(); +const {graphFromPackageDependencies} = await esmock.p("../../../lib/graph/graph.js", {}, { + "@parcel/watcher": {default: watcherMock.api, ...watcherMock.api} +}); +const FixtureTester = createFixtureTesterFactory("tags", {graphFromPackageDependencies, watcherMock}); +registerBuildHooks(test, {watcherMock}); + +for (const stepBased of [false, true]) { + test.serial(`Serving preserves tag isolation after cache restore (stepBased=${stepBased})`, async (t) => { + const tester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); + const {triggerPath} = await configureTagIsolation(tester.fixturePath, stepBased); + await tester.serveProject(); + const first = await tester.requestResource({resource: "/tag-report.txt"}); + t.is(await first.getString(), "input-1:trigger-1"); + await tester.teardown(); + + // Change only the consumer before restoring the dependency from the persistent cache. + await fs.writeFile(triggerPath, "trigger-2"); + await tester.serveProject(); + const second = await tester.requestResource({resource: "/tag-report.txt"}); + t.is(await second.getString(), "input-1:trigger-2"); + assertTagTasks(t, {setterSkipped: true}); + + await fs.writeFile(triggerPath, "trigger-3"); + await tester.fireWatcherEvent("update", triggerPath); + const third = await tester.requestResource({resource: "/tag-report.txt"}); + t.is(await third.getString(), "input-1:trigger-3"); + assertTagTasks(t, {setterSkipped: true}); + }); +} diff --git a/packages/project/test/lib/build/ProjectBuilder.tags.integration.js b/packages/project/test/lib/build/ProjectBuilder.tags.integration.js new file mode 100644 index 00000000000..cfc99847e65 --- /dev/null +++ b/packages/project/test/lib/build/ProjectBuilder.tags.integration.js @@ -0,0 +1,41 @@ +import test from "ava"; +import fs from "node:fs/promises"; +import {createFixtureTesterFactory, registerBuildHooks} from "./__helper__/ProjectBuilderFixtureTester.js"; +import {configureTagIsolation, assertTagTasks} from "./__helper__/tagIsolation.js"; + +const FixtureTester = createFixtureTesterFactory("tags"); +registerBuildHooks(test); + +for (const stepBased of [false, true]) { + test.serial(`Build tags stay local across cold, warm and changed builds (stepBased=${stepBased})`, async (t) => { + const tester = new FixtureTester(t, "application.a"); + await tester._initialize(); + const {inputPath, triggerPath} = await configureTagIsolation(tester.fixturePath, stepBased); + const config = { + destPath: tester.destPath, cleanDest: true, dependencyIncludes: {includeAllDependencies: true} + }; + const assertOutput = async (expected) => { + t.is(await fs.readFile(`${tester.destPath}/tag-report.txt`, "utf8"), expected); + await t.throwsAsync(fs.stat(`${tester.destPath}/tag-omitted.txt`), {code: "ENOENT"}); + await t.throwsAsync(fs.stat(`${tester.destPath}/resources/library/d/tag-input.txt`), {code: "ENOENT"}); + }; + + await tester.buildProject({config}); + assertTagTasks(t, {setterSkipped: false, producerExecuted: true}); + await assertOutput("input-1:trigger-1"); + + await tester.buildProject({config, assertions: {projects: {}}}); + await assertOutput("input-1:trigger-1"); + + // Only the reader's input changes. The setter must restore its tags from its cached stage. + await fs.writeFile(triggerPath, "trigger-2"); + await tester.buildProject({config}); + assertTagTasks(t, {setterSkipped: true}); + await assertOutput("input-1:trigger-2"); + + await fs.writeFile(inputPath, "input-2"); + await tester.buildProject({config}); + assertTagTasks(t, {setterSkipped: false, producerExecuted: true}); + await assertOutput("input-2:trigger-2"); + }); +} diff --git a/packages/project/test/lib/build/__helper__/tagIsolation.js b/packages/project/test/lib/build/__helper__/tagIsolation.js new file mode 100644 index 00000000000..21fa1dcb8e8 --- /dev/null +++ b/packages/project/test/lib/build/__helper__/tagIsolation.js @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import {load, dump} from "js-yaml"; + +export async function configureTagIsolation(fixturePath, stepBased) { + const libraryPath = path.join(fixturePath, "node_modules/library.d"); + const libraryConfigPath = path.join(libraryPath, "ui5.yaml"); + const libraryConfig = load(await fs.readFile(libraryConfigPath, "utf8")); + libraryConfig.builder = {customTasks: [{name: "tag-producer", afterTask: "minify"}]}; + await fs.writeFile(libraryConfigPath, dump(libraryConfig)); + + const definitions = [ + { + specVersion: "5.0", type: "application", metadata: {name: "application.a"}, + builder: {customTasks: [ + {name: "tag-setter", afterTask: "minify"}, + {name: "tag-reader", afterTask: "tag-setter"} + ]} + }, + ...[ + ["tag-producer", "producer.cjs"], + ["tag-setter", stepBased ? "step-setter.cjs" : "setter.cjs"], + ["tag-reader", "reader.cjs"] + ].map(([name, file]) => ({ + specVersion: "5.0", kind: "extension", type: "task", metadata: {name}, + task: {path: `tag-isolation/${file}`} + })) + ]; + await fs.writeFile(path.join(fixturePath, "ui5.yaml"), definitions.map((doc) => dump(doc)).join("---\n")); + const inputPath = path.join(libraryPath, "main/src/library/d/tag-input.txt"); + const triggerPath = path.join(fixturePath, "webapp/tag-trigger.txt"); + await fs.writeFile(inputPath, "input-1"); + await fs.writeFile(triggerPath, "trigger-1"); + return {inputPath, triggerPath}; +} + +export function assertTagTasks(t, {setterSkipped, producerExecuted = false}) { + const events = t.context.projectBuildStatusEventStub.args.map(([event]) => event); + t.true(events.some((event) => event.projectName === "application.a" && + event.taskName === "tag-reader" && event.status === "task-start"), "The tag reader executed"); + t.true(events.some((event) => event.projectName === "application.a" && + event.taskName === "tag-setter" && event.status === (setterSkipped ? "task-skip" : "task-start")), + setterSkipped ? "The setter's stage was restored from cache" : "The tag setter executed"); + t.is(events.some((event) => event.projectName === "library.d" && event.status === "task-start"), + producerExecuted, producerExecuted ? "The dependency executed tasks" : "The dependency used its cached result"); +} diff --git a/packages/project/test/lib/build/helpers/ProjectBuildContext.js b/packages/project/test/lib/build/helpers/ProjectBuildContext.js index 43ade112b54..87258067a4b 100644 --- a/packages/project/test/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/test/lib/build/helpers/ProjectBuildContext.js @@ -2,6 +2,8 @@ import test from "ava"; import sinon from "sinon"; import esmock from "esmock"; import ResourceTagCollection from "@ui5/fs/internal/ResourceTagCollection"; +import {createResource} from "@ui5/fs/resourceFactory"; +import ProjectResources from "../../../../lib/resources/ProjectResources.js"; test.beforeEach((t) => { t.context.resourceTagCollection = new ResourceTagCollection({ @@ -137,67 +139,72 @@ test("executeCleanupTasks", (t) => { t.is(task2.callCount, 1, "my task 2", "Cleanup task 2 got called"); }); -test.serial("getResourceTagCollection", async (t) => { - const ProjectBuildContext = (await import("../../../../lib/build/helpers/ProjectBuildContext.js")).default; - const buildContext = createBuildContextStub(); +function createTagProject(name) { + const resources = new ProjectResources({getName: () => name, createWriter: () => ({})}); const project = { - getName: () => "project", - getType: () => "type", + getName: () => name, + getType: () => "library", + getProjectResources: () => resources, + getResourceTagCollection: (resource, tag) => resources.getResourceTagCollection(resource, tag), }; - const projectBuildContext = new ProjectBuildContext( - buildContext, - project - ); + const context = new ProjectBuildContext(createBuildContextStub(), project); + return {project, resources, taskUtil: context.getTaskUtil()}; +} - const fakeCollection = { - acceptsTag: sinon.stub().returns(true) - }; - const getResourceTagCollectionStub = sinon.stub().returns(fakeCollection); - const fakeResource = { - getProject: () => { - return { - getResourceTagCollection: getResourceTagCollectionStub - }; - }, - getPath: () => "/resource/path", - hasProject: () => true - }; - const collection = projectBuildContext.getResourceTagCollection(fakeResource, "some-tag"); - t.is(collection, fakeCollection, "Returned tag collection from resource's project"); - t.is(getResourceTagCollectionStub.callCount, 1, "getResourceTagCollection called once"); - t.is(getResourceTagCollectionStub.firstCall.args[0], fakeResource, "Called with resource"); - t.is(getResourceTagCollectionStub.firstCall.args[1], "some-tag", "Called with tag"); -}); +for (const tag of ["ui5:OmitFromBuildResult", "ui5:IsBundle", "build:CustomTag"]) { + test(`Build tag ${tag} on a dependency belongs to the executing project`, (t) => { + const dependency = createTagProject("dependency"); + const first = createTagProject("first"); + const second = createTagProject("second"); + const resource = createResource({path: "/resources/dependency/file.js", project: dependency.project}); + dependency.taskUtil.setTag(resource, tag, "producer"); + t.is(first.taskUtil.getTag(resource, tag), undefined, "The producer's tag is not inherited"); + first.taskUtil.setTag(resource, tag, "first"); + second.taskUtil.setTag(resource, tag, "second"); + t.is(first.taskUtil.getTag(resource, tag), "first"); + t.is(second.taskUtil.getTag(resource, tag), "second"); + t.is(dependency.taskUtil.getTag(resource, tag), "producer"); + t.is(first.resources.getResourceTagOperations().buildTagOperations.get(resource.getPath()).get(tag), "first", + "The executing project's stage records the tag"); + first.taskUtil.clearTag(resource, tag); + t.is(first.taskUtil.getTag(resource, tag), undefined); + t.is(second.taskUtil.getTag(resource, tag), "second", "Clearing a tag is local to the executing project"); + first.resources.useResultStage(); + first.resources.buildFinished(); + t.is(second.taskUtil.getTag(resource, tag), "second", "Finishing another project preserves local tags"); + t.is(resource.getProject(), dependency.project, "Tagging preserves resource ownership"); + }); +} + +for (const tag of ["ui5:IsDebugVariant", "ui5:HasDebugVariant", "project:CustomTag"]) { + test(`Project tag ${tag} on a dependency remains shared`, (t) => { + const dependency = createTagProject("dependency"); + const first = createTagProject("first"); + const second = createTagProject("second"); + const resource = createResource({path: "/resources/dependency/file.js", project: dependency.project}); + first.taskUtil.setTag(resource, tag); + t.true(second.taskUtil.getTag(resource, tag)); + t.true(dependency.taskUtil.getTag(resource, tag)); + second.taskUtil.clearTag(resource, tag); + t.is(first.taskUtil.getTag(resource, tag), undefined); + t.is(first.resources.getResourceTagOperations().projectTagOperations.size, 0); + }); +} test("getResourceTagCollection: Assigns project to resource if necessary", (t) => { - const fakeProject = { - getName: () => "project", - getType: () => "type", - }; - const buildContext = createBuildContextStub(); - const projectBuildContext = new ProjectBuildContext( - buildContext, - fakeProject - ); + const {project, taskUtil} = createTagProject("project"); + const resource = createResource({path: "/resource/path"}); + taskUtil.setTag(resource, "build:MyTag"); + t.is(resource.getProject(), project); + t.true(taskUtil.getTag(resource, "build:MyTag")); +}); - const setProjectStub = sinon.stub(); - const fakeResource = { - getProject: () => { - return { - getResourceTagCollection: () => { - return { - acceptsTag: () => false - }; - } - }; - }, - getPath: () => "/resource/path", - hasProject: () => false, - setProject: setProjectStub - }; - projectBuildContext.getResourceTagCollection(fakeResource, "build:MyTag"); - t.is(setProjectStub.callCount, 1, "setProject got called once"); - t.is(setProjectStub.getCall(0).args[0], fakeProject, "setProject got called with correct argument"); +test("getResourceTagCollection: Rejects unsupported tags", (t) => { + const {taskUtil} = createTagProject("project"); + const resource = createResource({path: "/resource/path"}); + t.throws(() => taskUtil.setTag(resource, "other:Tag"), { + message: "Could not find collection for resource /resource/path and tag other:Tag" + }); }); test("getProject", (t) => { diff --git a/packages/project/test/lib/resources/ProjectResources.js b/packages/project/test/lib/resources/ProjectResources.js index c82ad3a7298..33e33f0a305 100644 --- a/packages/project/test/lib/resources/ProjectResources.js +++ b/packages/project/test/lib/resources/ProjectResources.js @@ -116,6 +116,25 @@ test("Cached build tags can be replayed in subsequent builds", (t) => { } }); +test("acceptsProjectTag classifies tags without importing operations or creating a monitor", (t) => { + const {pr, writer} = createProjectResources(); + const resource = createTagResource(pr); + const tags = new Map([["project:State", "original"]]); + pr.initStages(["task/cached", "task/next"]); + pr.setStage("task/cached", writer, new Map([[resource.getPath(), tags]]), new Map()); + pr.useStage("task/next"); + for (const tag of [undefined, "ui5:IsDebugVariant", "ui5:HasDebugVariant", "project:State"]) { + t.true(pr.acceptsProjectTag(tag)); + } + for (const tag of ["ui5:IsBundle", "ui5:OmitFromBuildResult", "build:State", "other:Tag"]) { + t.false(pr.acceptsProjectTag(tag)); + } + t.is(pr.getResourceTagOperations().projectTagOperations.size, 0); + t.is(pr.getResourceTagOperations().buildTagOperations.size, 0); + tags.set("project:State", "updated"); + t.is(resource.getTags()["project:State"], "updated", "The first read imports and snapshots the cached tags"); +}); + test("Build tags do not share storage with build manifest tags", (t) => { const tags = {"/resources/test/file.js": {"ui5:HasDebugVariant": true}}; const {pr} = createProjectResources({buildManifest: {tags}});