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/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 292c1906305..f99a2d7d856 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,16 +420,28 @@ 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; + } + + /** + * 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 @@ -567,8 +577,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/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/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(); 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/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..33e33f0a305 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,122 @@ 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("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}}); + 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) => {