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"); });