diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js
index 55e78334f99..6f15defcc0e 100644
--- a/packages/logger/lib/writers/InteractiveConsole.js
+++ b/packages/logger/lib/writers/InteractiveConsole.js
@@ -4,7 +4,12 @@ import sliceAnsi from "slice-ansi";
import Logger from "../loggers/Logger.js";
import {formatLogLine, prefixModuleName} from "./internal/format.js";
import {createHeaderState, setTool} from "./interactiveConsole/state/header.js";
-import {createProjectState, setProject, enableProjectPlaceholders} from "./interactiveConsole/state/project.js";
+import {
+ createProjectState,
+ setProject,
+ setFramework,
+ enableProjectPlaceholders,
+} from "./interactiveConsole/state/project.js";
import {createServerState, setListening, enableServerPlaceholders} from "./interactiveConsole/state/server.js";
import {
createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, STATES,
@@ -45,9 +50,11 @@ function parseWriteArgs(encodingOrCallback, maybeCallback) {
* No command knowledge. Regions render top-to-bottom in a fixed order —
* header, root project, server, build status — and hidden regions collapse.
*
- * All state is event-driven. See the design at
- * docs/interactive-console-writer.md and the public event API
- * exposed by @ui5/logger.
+ * All state is event-driven. It reacts to process events emitted across
+ * @ui5/logger and related packages, including CLI, project,
+ * server, build, serve, and log events. These event shapes are internal
+ * cross-package contracts within the UI5 CLI codebase and should be kept
+ * stable for their in-repo consumers.
*
* @public
* @class
@@ -93,6 +100,7 @@ class InteractiveConsole {
#onToolInfo;
#onToolMode;
#onProjectResolved;
+ #onProjectFrameworkResolved;
#onServerListening;
#onStopConsole;
#onResize;
@@ -230,6 +238,7 @@ class InteractiveConsole {
this.#onToolInfo = (evt) => this.#handleToolInfo(evt);
this.#onToolMode = (evt) => this.#handleToolMode(evt);
this.#onProjectResolved = (evt) => this.#handleProjectResolved(evt);
+ this.#onProjectFrameworkResolved = (evt) => this.#handleProjectFrameworkResolved(evt);
this.#onServerListening = (evt) => this.#handleServerListening(evt);
this.#onStopConsole = () => this.disable();
this.#onResize = () => this.#handleResize();
@@ -242,6 +251,7 @@ class InteractiveConsole {
process.on("ui5.tool-info", this.#onToolInfo);
process.on("ui5.tool-mode", this.#onToolMode);
process.on("ui5.project-resolved", this.#onProjectResolved);
+ process.on("ui5.project-framework-resolved", this.#onProjectFrameworkResolved);
process.on("ui5.server-listening", this.#onServerListening);
process.on("ui5.log.stop-console", this.#onStopConsole);
if (typeof this.#stderr.on === "function") {
@@ -258,6 +268,7 @@ class InteractiveConsole {
process.off("ui5.tool-info", this.#onToolInfo);
process.off("ui5.tool-mode", this.#onToolMode);
process.off("ui5.project-resolved", this.#onProjectResolved);
+ process.off("ui5.project-framework-resolved", this.#onProjectFrameworkResolved);
process.off("ui5.server-listening", this.#onServerListening);
process.off("ui5.log.stop-console", this.#onStopConsole);
if (typeof this.#stderr.off === "function") {
@@ -299,10 +310,10 @@ class InteractiveConsole {
#handleProjectResolved(evt) {
if (this.#seenProjectResolved) {
- // See docs/interactive-console-writer.md § Ordering rules: the
- // writer's model is single-root-project. Two events means the
- // caller's invariant is violated and any subsequent event
- // attribution is ambiguous.
+ // The writer's model is single-root-project. A second
+ // `ui5.project-resolved` event means the emitter violated that
+ // invariant, making subsequent event attribution ambiguous, so fail
+ // fast instead of trying to deduplicate.
throw new Error(
`writers/InteractiveConsole: Received duplicate ui5.project-resolved event`);
}
@@ -311,6 +322,11 @@ class InteractiveConsole {
this.#render();
}
+ #handleProjectFrameworkResolved({framework}) {
+ setFramework(this.#projectState, framework);
+ this.#render();
+ }
+
#handleServerListening(evt) {
setListening(this.#serverState, evt);
this.#render();
diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js
index b189ba617bf..ab023741162 100644
--- a/packages/logger/lib/writers/interactiveConsole/render.js
+++ b/packages/logger/lib/writers/interactiveConsole/render.js
@@ -59,7 +59,7 @@ export function renderProjectRegion(projectState) {
}
} else {
// Placeholder mode: reserve only the Project row. The Framework row is
- // deferred until project-resolved reveals a real framework name.
+ // deferred until framework resolution reveals a real framework name.
lines.push(`${chalk.dim("Project")} ${placeholder("resolving…")}`);
}
return lines;
diff --git a/packages/logger/lib/writers/interactiveConsole/state/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js
index f92a81622fa..f0a58286fc9 100644
--- a/packages/logger/lib/writers/interactiveConsole/state/project.js
+++ b/packages/logger/lib/writers/interactiveConsole/state/project.js
@@ -1,4 +1,6 @@
-// Region 2 — root project. Populated by `ui5.project-resolved`.
+// Region 2 — root project. Populated by `ui5.project-resolved` and, when
+// framework usage is actually resolved for the current run, by
+// `ui5.project-framework-resolved`.
export function createProjectState() {
return {
project: null, // {name, type, version}
@@ -12,7 +14,13 @@ export function createProjectState() {
export function setProject(state, evt) {
state.project = {name: evt.name, type: evt.type, version: evt.version};
- state.framework = evt.framework ? {name: evt.framework.name, version: evt.framework.version} : null;
+}
+
+export function setFramework(state, framework) {
+ state.framework = framework ? {
+ name: framework.name,
+ version: framework.version,
+ } : null;
}
export function enableProjectPlaceholders(state) {
diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js
index 936a9dcbfa3..476c7721f01 100644
--- a/packages/logger/test/lib/writers/InteractiveConsole.js
+++ b/packages/logger/test/lib/writers/InteractiveConsole.js
@@ -51,12 +51,30 @@ test.serial("project-resolved populates the project region", (t) => {
name: "my.app",
type: "application",
version: "1.0.0",
- framework: {name: "SAPUI5", version: "1.150.0"},
});
const state = writer._getStateForTest();
t.deepEqual(state.project.project, {name: "my.app", type: "application", version: "1.0.0"});
- t.deepEqual(state.project.framework, {name: "SAPUI5", version: "1.150.0"});
+ t.is(state.project.framework, null);
+
+ writer.disable();
+});
+
+test.serial("project-framework-resolved populates the framework region", (t) => {
+ const {writer} = createWriter();
+
+ process.emit("ui5.project-framework-resolved", {
+ framework: {
+ name: "SAPUI5",
+ version: "1.150.0",
+ },
+ });
+
+ const state = writer._getStateForTest();
+ t.deepEqual(state.project.framework, {
+ name: "SAPUI5",
+ version: "1.150.0",
+ });
writer.disable();
});
@@ -68,7 +86,6 @@ test.serial("duplicate project-resolved throws", (t) => {
name: "my.app",
type: "application",
version: "1.0.0",
- framework: null,
});
// The writer's model is single-root-project. A second event means the
@@ -78,7 +95,6 @@ test.serial("duplicate project-resolved throws", (t) => {
name: "other.app",
type: "application",
version: "2.0.0",
- framework: null,
});
}, {
message: /duplicate ui5\.project-resolved/,
@@ -174,7 +190,7 @@ test.serial("regions are order-tolerant — server before project", (t) => {
acceptRemoteConnections: false,
});
process.emit("ui5.project-resolved", {
- name: "my.app", type: "application", version: "1.0.0", framework: null,
+ name: "my.app", type: "application", version: "1.0.0",
});
const state = writer._getStateForTest();
@@ -288,7 +304,12 @@ test.serial("frame includes visible content for each populated region", (t) => {
process.emit("ui5.tool-info", {name: "UI5 CLI", version: "1.2.3"});
process.emit("ui5.project-resolved", {
name: "my.app", type: "application", version: "1.0.0",
- framework: {name: "SAPUI5", version: "1.150.0"},
+ });
+ process.emit("ui5.project-framework-resolved", {
+ framework: {
+ name: "SAPUI5",
+ version: "1.150.0",
+ },
});
process.emit("ui5.server-listening", {
urls: [{label: "Local", url: "http://localhost:8080"}],
@@ -361,7 +382,12 @@ test.serial("tool-mode 'serve' placeholders are replaced by real data", (t) => {
process.emit("ui5.project-resolved", {
name: "my.app", type: "application", version: "1.0.0",
- framework: {name: "SAPUI5", version: "1.150.0"},
+ });
+ process.emit("ui5.project-framework-resolved", {
+ framework: {
+ name: "SAPUI5",
+ version: "1.150.0",
+ },
});
process.emit("ui5.server-listening", {
urls: [{label: "Local", url: "http://localhost:8080"}],
@@ -372,6 +398,10 @@ test.serial("tool-mode 'serve' placeholders are replaced by real data", (t) => {
// State reflects real data now — the placeholder rendering path is gone.
const state = writer._getStateForTest();
t.deepEqual(state.project.project, {name: "my.app", type: "application", version: "1.0.0"});
+ t.deepEqual(state.project.framework, {
+ name: "SAPUI5",
+ version: "1.150.0",
+ });
t.truthy(state.server.urls);
t.is(state.build.state, STATES.READY);
@@ -410,7 +440,7 @@ test.serial("region blocks are separated by a blank line in the composed frame",
setTool(header, {name: "UI5 CLI", version: "1.2.3"});
const project = createProjectState();
setProject(project, {
- name: "my.app", type: "application", version: "1.0.0", framework: null,
+ name: "my.app", type: "application", version: "1.0.0",
});
const server = createServerState();
setListening(server, {
diff --git a/packages/logger/test/lib/writers/interactiveConsole/render.js b/packages/logger/test/lib/writers/interactiveConsole/render.js
index 2bf541e1102..869838e9bdf 100644
--- a/packages/logger/test/lib/writers/interactiveConsole/render.js
+++ b/packages/logger/test/lib/writers/interactiveConsole/render.js
@@ -22,7 +22,7 @@ import {
} from "../../../../lib/writers/interactiveConsole/state/build.js";
import {createHeaderState, setTool} from
"../../../../lib/writers/interactiveConsole/state/header.js";
-import {createProjectState, setProject, enableProjectPlaceholders} from
+import {createProjectState, setProject, setFramework, enableProjectPlaceholders} from
"../../../../lib/writers/interactiveConsole/state/project.js";
import {createServerState, setListening, enableServerPlaceholders} from
"../../../../lib/writers/interactiveConsole/state/server.js";
@@ -83,8 +83,8 @@ test("renderProjectRegion: renders project, type, and version", (t) => {
name: "my.app",
type: "application",
version: "1.0.0",
- framework: {name: "SAPUI5", version: "1.150.0"},
});
+ setFramework(state, {name: "SAPUI5", version: "1.150.0"});
const plain = renderProjectRegion(state).map(stripAnsi).join("\n");
t.regex(plain, /Project\s+my\.app\s+\(application\)\s+v1\.0\.0/);
t.regex(plain, /Framework\s+SAPUI5 1\.150\.0/);
@@ -96,8 +96,8 @@ test("renderProjectRegion: renders framework without a version when only the nam
name: "my.app",
type: "application",
version: "1.0.0",
- framework: {name: "OpenUI5"},
});
+ setFramework(state, {name: "OpenUI5"});
const plain = renderProjectRegion(state).map(stripAnsi).join("\n");
t.regex(plain, /Framework\s+OpenUI5$/m);
});
@@ -112,7 +112,6 @@ test("renderProjectRegion: omits the framework row when project has no framework
name: "my.app",
type: "application",
version: "1.0.0",
- framework: null,
});
const rendered = renderProjectRegion(state);
t.is(rendered.length, 2, "two lines: separator + Project");
diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/project.js b/packages/logger/test/lib/writers/interactiveConsole/state/project.js
index e1f08b79585..6a8d180272f 100644
--- a/packages/logger/test/lib/writers/interactiveConsole/state/project.js
+++ b/packages/logger/test/lib/writers/interactiveConsole/state/project.js
@@ -1,6 +1,6 @@
import test from "ava";
-import {createProjectState, setProject, enableProjectPlaceholders} from
+import {createProjectState, setProject, setFramework, enableProjectPlaceholders} from
"../../../../../lib/writers/interactiveConsole/state/project.js";
test("createProjectState: fresh state has no project/framework and placeholders disabled", (t) => {
@@ -17,16 +17,28 @@ test("setProject: keeps only {name, type, version} from the incoming event", (t)
name: "my.app",
type: "application",
version: "1.0.0",
- framework: {name: "SAPUI5", version: "1.150.0"},
extraNoise: "dropped",
});
t.deepEqual(state.project, {name: "my.app", type: "application", version: "1.0.0"});
- t.deepEqual(state.framework, {name: "SAPUI5", version: "1.150.0"});
+ t.is(state.framework, null);
+});
+
+test("setFramework: keeps known framework fields from the incoming event", (t) => {
+ const state = createProjectState();
+ setFramework(state, {
+ name: "SAPUI5",
+ version: "1.150.0",
+ extraNoise: "dropped",
+ });
+ t.deepEqual(state.framework, {
+ name: "SAPUI5",
+ version: "1.150.0",
+ });
});
-test("setProject: framework becomes null when the event omits one", (t) => {
+test("setFramework: framework becomes null when the event omits one", (t) => {
const state = createProjectState();
- setProject(state, {name: "my.app", type: "application", version: "1.0.0", framework: null});
+ setFramework(state, null);
t.is(state.framework, null);
});
diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js
index 660cc78427e..d7dd6893173 100644
--- a/packages/project/lib/graph/helpers/ui5Framework.js
+++ b/packages/project/lib/graph/helpers/ui5Framework.js
@@ -1,5 +1,6 @@
import Module from "../Module.js";
import ProjectGraph from "../ProjectGraph.js";
+import process from "node:process";
import {getLogger} from "@ui5/logger";
const log = getLogger("graph:helpers:ui5Framework");
import Configuration from "../../config/Configuration.js";
@@ -397,6 +398,15 @@ export default {
const {libraryMetadata} = await resolver.install(referencedLibraries);
+ if (version) {
+ process.emit("ui5.project-framework-resolved", {
+ framework: {
+ name: frameworkName,
+ version,
+ },
+ });
+ }
+
if (log.isLevelEnabled("verbose")) {
const timeDiff = process.hrtime(startTime);
const {default: prettyHrtime} = await import("pretty-hrtime");
diff --git a/packages/project/lib/graph/projectGraphBuilder.js b/packages/project/lib/graph/projectGraphBuilder.js
index 5ecb20073dc..4c404559ba3 100644
--- a/packages/project/lib/graph/projectGraphBuilder.js
+++ b/packages/project/lib/graph/projectGraphBuilder.js
@@ -141,15 +141,12 @@ async function projectGraphBuilder(nodeProvider, workspace) {
// Announce the resolved root project on the event bus, before dependency
// traversal. Consumed by @ui5/logger writers to populate their header /
- // scrollback lines. Framework name/version may be null for projects without
- // a UI5 framework dependency.
- const frameworkName = rootProject.getFrameworkName?.();
- const frameworkVersion = rootProject.getFrameworkVersion?.();
+ // scrollback lines. Framework information is emitted separately once a
+ // caller actually resolves framework usage for the current run.
process.emit("ui5.project-resolved", {
name: rootProject.getName(),
type: rootProject.getType(),
version: rootProject.getVersion(),
- framework: frameworkName ? {name: frameworkName, version: frameworkVersion} : null,
});
function handleExtensions(extensions) {
diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.js b/packages/project/test/lib/graph/helpers/ui5Framework.js
index b134ac187ac..d6115df20f3 100644
--- a/packages/project/test/lib/graph/helpers/ui5Framework.js
+++ b/packages/project/test/lib/graph/helpers/ui5Framework.js
@@ -84,6 +84,10 @@ test.afterEach.always((t) => {
test.serial("enrichProjectGraph", async (t) => {
const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub} = t.context;
+ const frameworkEvents = [];
+ const frameworkListener = (evt) => frameworkEvents.push(evt);
+ process.on("ui5.project-framework-resolved", frameworkListener);
+ t.teardown(() => process.off("ui5.project-framework-resolved", frameworkListener));
const dependencyTree = {
id: "test1",
@@ -168,6 +172,13 @@ test.serial("enrichProjectGraph", async (t) => {
t.deepEqual(callbackCalls, [
"application.a"
], "Traversed graph in correct order");
+
+ t.deepEqual(frameworkEvents, [{
+ framework: {
+ name: "SAPUI5",
+ version: "1.75.0",
+ },
+ }], "Emits the effective framework metadata for the current run");
});
test.serial("enrichProjectGraph: without framework configuration", async (t) => {
@@ -293,6 +304,10 @@ test.serial("enrichProjectGraph: With versionOverride", async (t) => {
sinon, ui5Framework, utils,
Sapui5ResolverStub, Sapui5ResolverResolveVersionStub, Sapui5ResolverInstallStub
} = t.context;
+ const frameworkEvents = [];
+ const frameworkListener = (evt) => frameworkEvents.push(evt);
+ process.on("ui5.project-framework-resolved", frameworkListener);
+ t.teardown(() => process.off("ui5.project-framework-resolved", frameworkListener));
const dependencyTree = {
id: "test1",
@@ -347,6 +362,12 @@ test.serial("enrichProjectGraph: With versionOverride", async (t) => {
ui5DataDir: undefined,
providedLibraryMetadata: undefined
}], "Sapui5Resolver#constructor should be called with expected args");
+ t.deepEqual(frameworkEvents, [{
+ framework: {
+ name: "SAPUI5",
+ version: "1.99.9",
+ },
+ }], "Emits the overridden effective framework metadata for the current run");
});
test.serial("enrichProjectGraph: With versionOverride containing snapshot version", async (t) => {
@@ -477,6 +498,11 @@ test.serial("enrichProjectGraph: With versionOverride containing latest-snapshot
test.serial("enrichProjectGraph shouldn't throw when no framework version and no libraries are provided", async (t) => {
const {ui5Framework, log, Sapui5ResolverResolveVersionStub} = t.context;
+ const frameworkListener = () => {
+ t.fail("Framework event must not be emitted when no libraries are used");
+ };
+ process.on("ui5.project-framework-resolved", frameworkListener);
+ t.teardown(() => process.off("ui5.project-framework-resolved", frameworkListener));
const dependencyTree = {
id: "test-id",
version: "1.2.3",
@@ -1009,6 +1035,11 @@ test.serial("enrichProjectGraph should use framework library metadata from works
test.serial("enrichProjectGraph should allow omitting framework version in case " +
"all framework libraries come from the workspace", async (t) => {
const {ui5Framework, utils, Sapui5ResolverStub, Sapui5ResolverInstallStub, sinon} = t.context;
+ const frameworkListener = () => {
+ t.fail("Framework event must not be emitted when no direct version is known");
+ };
+ process.on("ui5.project-framework-resolved", frameworkListener);
+ t.teardown(() => process.off("ui5.project-framework-resolved", frameworkListener));
const dependencyTree = {
id: "@sapui5/project",
version: "1.2.3",
diff --git a/packages/project/test/lib/graph/projectGraphBuilder.js b/packages/project/test/lib/graph/projectGraphBuilder.js
index 46cbfefabc8..67b36ed4647 100644
--- a/packages/project/test/lib/graph/projectGraphBuilder.js
+++ b/packages/project/test/lib/graph/projectGraphBuilder.js
@@ -983,6 +983,5 @@ test.serial("Emits ui5.project-resolved with the root project's shape", async (t
t.is(events[0].name, "root.project");
t.is(events[0].type, "library");
t.is(events[0].version, "1.0.0");
- // The test's library.e fixture is not a framework project, so framework is null.
- t.is(events[0].framework, null);
+ t.false(Object.hasOwn(events[0], "framework"), "Framework details are emitted separately");
});