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
7 changes: 2 additions & 5 deletions packages/project/lib/build/cache/ProjectBuildCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions packages/project/lib/build/helpers/ProjectBuildContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/project/lib/build/helpers/TaskUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
});
Expand Down
33 changes: 21 additions & 12 deletions packages/project/lib/resources/ProjectResources.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -283,19 +286,14 @@ 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 <code>initial</code> stage,
* so the build cache re-initializes stages and re-imports cached results.
*
* @public
*/
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;
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions packages/project/lib/specifications/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
};
};
Original file line number Diff line number Diff line change
@@ -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
};
};
Original file line number Diff line number Diff line change
@@ -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
};
};
Original file line number Diff line number Diff line change
@@ -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
};
};
38 changes: 38 additions & 0 deletions packages/project/test/lib/build/BuildServer.tags.integration.js
Original file line number Diff line number Diff line change
@@ -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});
});
}
41 changes: 41 additions & 0 deletions packages/project/test/lib/build/ProjectBuilder.tags.integration.js
Original file line number Diff line number Diff line change
@@ -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");
});
}
46 changes: 46 additions & 0 deletions packages/project/test/lib/build/__helper__/tagIsolation.js
Original file line number Diff line number Diff line change
@@ -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");
}
9 changes: 7 additions & 2 deletions packages/project/test/lib/build/cache/ProjectBuildCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down Expand Up @@ -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",
Expand All @@ -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();

Expand Down
Loading
Loading