From a9b0e55bcbe59d13daaaf256e43b5a76c931aafa Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Thu, 2 Jul 2026 15:05:50 +0200 Subject: [PATCH 01/16] refactor(logger): Use pretty-hrtime in Serve buildDone fallback Replaces the hand-rolled hrtime formatter in the no-listener fallback path of Serve#buildDone with pretty-hrtime, which is already used across @ui5/cli, @ui5/fs, and @ui5/project. Follow-up to #1439. --- package-lock.json | 3 ++- packages/logger/lib/loggers/Serve.js | 11 ++--------- packages/logger/package.json | 3 ++- packages/logger/test/lib/loggers/Serve.js | 4 ++-- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index cce2c4f4624..89e5bc98280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18475,7 +18475,8 @@ "dependencies": { "chalk": "^5.6.2", "cli-progress": "^3.12.0", - "figures": "^6.1.0" + "figures": "^6.1.0", + "pretty-hrtime": "^1.0.3" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/logger/lib/loggers/Serve.js b/packages/logger/lib/loggers/Serve.js index 18b2e75e254..b9d27f8d63d 100644 --- a/packages/logger/lib/loggers/Serve.js +++ b/packages/logger/lib/loggers/Serve.js @@ -1,3 +1,4 @@ +import prettyHrtime from "pretty-hrtime"; import Logger from "./Logger.js"; /** @@ -67,15 +68,7 @@ class Serve extends Logger { hrtime, }); if (!hasListeners) { - // Plain-text fallback for the no-listener path. The banner formats - // via pretty-hrtime; this stays inline to avoid pulling a new - // dependency into @ui5/logger. - const [seconds, nanoseconds] = hrtime; - const totalMs = seconds * 1000 + nanoseconds / 1e6; - const formatted = totalMs >= 1000 ? - `${(totalMs / 1000).toFixed(2)}s` : - `${Math.round(totalMs)}ms`; - this._log(level, `Build finished in ${formatted}`); + this._log(level, `Build finished in ${prettyHrtime(hrtime)}`); } } diff --git a/packages/logger/package.json b/packages/logger/package.json index 5286b7452d6..4f69f455d35 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -50,7 +50,8 @@ "dependencies": { "chalk": "^5.6.2", "cli-progress": "^3.12.0", - "figures": "^6.1.0" + "figures": "^6.1.0", + "pretty-hrtime": "^1.0.3" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/logger/test/lib/loggers/Serve.js b/packages/logger/test/lib/loggers/Serve.js index 2c8adabab73..0244f4177e5 100644 --- a/packages/logger/test/lib/loggers/Serve.js +++ b/packages/logger/test/lib/loggers/Serve.js @@ -145,7 +145,7 @@ test.serial("No event listener: buildDone", (t) => { serveLogger.buildDone([0, 123000000]); t.is(logStub.callCount, 1, "_log got called once"); t.is(logStub.getCall(0).args[0], "info", "Logged with expected log-level"); - t.is(logStub.getCall(0).args[1], "Build finished in 123ms", "Logged expected message"); + t.is(logStub.getCall(0).args[1], "Build finished in 123 ms", "Logged expected message"); t.is(logHandler.callCount, 0, "No log event emitted"); }); @@ -155,7 +155,7 @@ test.serial("No event listener: buildDone formats durations >= 1s in seconds", ( serveLogger.buildDone([2, 345000000]); t.is(logStub.callCount, 1, "_log got called once"); t.is(logStub.getCall(0).args[0], "info", "Logged with expected log-level"); - t.is(logStub.getCall(0).args[1], "Build finished in 2.35s", "Logged expected message"); + t.is(logStub.getCall(0).args[1], "Build finished in 2.34 s", "Logged expected message"); t.is(logHandler.callCount, 0, "No log event emitted"); }); From af47ad2ae16a4e9946d577494b6d2e52fb95294a Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Thu, 2 Jul 2026 15:05:55 +0200 Subject: [PATCH 02/16] docs(cli): Refine serve state.js header comment Make explicit that event handlers, not only setters, mutate banner state and trigger renders. Follow-up to #1439. --- packages/cli/lib/serve/state.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/lib/serve/state.js b/packages/cli/lib/serve/state.js index d13d18ee7b4..f431be9773f 100644 --- a/packages/cli/lib/serve/state.js +++ b/packages/cli/lib/serve/state.js @@ -1,6 +1,6 @@ -// Pure state holder for the live banner. Mutated directly by Banner setters, -// each of which calls `Banner.prototype.#render` after updating the fields it -// owns. +// Pure state holder for the live banner. Mutated directly by Banner setters +// and event handlers, each of which calls `Banner.prototype.#render` after +// updating the fields it owns. export const STATES = Object.freeze({ INITIAL: "initial", From b92b668b1f3e3fe32aafd4517f4c1cc0ce636112 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 11:42:58 +0200 Subject: [PATCH 03/16] refactor(logger): Move ui5 serve banner into InteractiveConsole writer Relocate the live status banner previously implemented in packages/cli/lib/serve/ into a new InteractiveConsole writer in @ui5/logger, split into render, format, and per-section state modules. The serve command and logger middleware wire the writer in place of the former Banner integration. --- packages/cli/lib/cli/commands/serve.js | 87 -- packages/cli/lib/cli/middlewares/logger.js | 34 +- packages/cli/lib/serve/render.js | 174 ---- packages/cli/lib/serve/state.js | 49 -- packages/cli/package.json | 2 - packages/cli/test/lib/cli/commands/serve.js | 804 ++---------------- packages/cli/test/lib/serve/Banner.js | 632 -------------- packages/cli/test/lib/serve/render.js | 327 ------- packages/logger/lib/writers/Console.js | 54 +- .../lib/writers/InteractiveConsole.js} | 416 +++++---- .../lib/writers/interactiveConsole/format.js | 31 + .../remoteConnectionsWarning.js | 6 +- .../lib/writers/interactiveConsole/render.js | 147 ++++ .../writers/interactiveConsole/state/build.js | 88 ++ .../interactiveConsole/state/header.js | 14 + .../interactiveConsole/state/project.js | 16 + .../interactiveConsole/state/server.js | 16 + .../lib/writers/internal/levelPrefix.js | 25 + packages/logger/package.json | 4 +- packages/logger/test/lib/package-exports.js | 1 + packages/logger/test/lib/writers/Console.js | 6 +- .../test/lib/writers/InteractiveConsole.js | 272 ++++++ .../project/lib/graph/projectGraphBuilder.js | 14 + .../test/lib/graph/projectGraphBuilder.js | 21 + packages/server/lib/server.js | 34 + 25 files changed, 1006 insertions(+), 2268 deletions(-) delete mode 100644 packages/cli/lib/serve/render.js delete mode 100644 packages/cli/lib/serve/state.js delete mode 100644 packages/cli/test/lib/serve/Banner.js delete mode 100644 packages/cli/test/lib/serve/render.js rename packages/{cli/lib/serve/Banner.js => logger/lib/writers/InteractiveConsole.js} (50%) create mode 100644 packages/logger/lib/writers/interactiveConsole/format.js rename packages/{cli/lib/serve => logger/lib/writers/interactiveConsole}/remoteConnectionsWarning.js (81%) create mode 100644 packages/logger/lib/writers/interactiveConsole/render.js create mode 100644 packages/logger/lib/writers/interactiveConsole/state/build.js create mode 100644 packages/logger/lib/writers/interactiveConsole/state/header.js create mode 100644 packages/logger/lib/writers/interactiveConsole/state/project.js create mode 100644 packages/logger/lib/writers/interactiveConsole/state/server.js create mode 100644 packages/logger/lib/writers/internal/levelPrefix.js create mode 100644 packages/logger/test/lib/writers/InteractiveConsole.js diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index e58b4158316..be1274c2166 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -2,35 +2,9 @@ import path from "node:path"; import os from "node:os"; import baseMiddleware from "../middlewares/base.js"; import {applyProjectConfigOptions, applyWorkspaceOptions, dedupeArray} from "../options.js"; -import {REMOTE_CONNECTIONS_WARNING_LINES} from "../../serve/remoteConnectionsWarning.js"; import {getLogger} from "@ui5/logger"; -import Logger from "@ui5/logger/Logger"; -import ConsoleWriter from "@ui5/logger/writers/Console"; -import {getVersion as getCliVersion} from "../version.js"; const log = getLogger("cli:commands:serve"); -// Log levels that fall back to the plain-output path because the live banner -// cannot represent the level's intent (firehose verbose logging) or because -// the user has asked for silence entirely. -const NON_BANNER_LEVELS = new Set(["perf", "verbose", "silly", "silent"]); - -// Collects all non-internal IPv4 addresses from the host's network -// interfaces so the banner can list every reachable URL when the server -// binds to all interfaces. Returns an empty array if no suitable address -// is found. -function findNetworkInterfaceAddresses() { - const interfaces = os.networkInterfaces(); - const addresses = []; - for (const name of Object.keys(interfaces)) { - for (const iface of interfaces[name] ?? []) { - if (iface.family === "IPv4" && !iface.internal) { - addresses.push(iface.address); - } - } - } - return addresses; -} - // Serve const serve = { command: "serve", @@ -160,28 +134,6 @@ serve.builder = function(cli) { }; serve.handler = async function(argv) { - const useBanner = - process.stdout.isTTY === true && - !NON_BANNER_LEVELS.has(Logger.getLevel()); - - let banner; - // Discover network addresses up front so the banner's initial paint can - // reserve the correct number of lines for the "Network:" section. Once the - // header is painted, swapping placeholder lines for real URLs without - // changing the line count keeps the live region from re-flowing. - const networkAddresses = (useBanner && argv.acceptRemoteConnections) ? - findNetworkInterfaceAddresses() : []; - if (useBanner) { - const {default: Banner} = await import("../../serve/Banner.js"); - // Banner takes over all output - ConsoleWriter.stop(); - banner = Banner.observe({ - brand: {name: "UI5 CLI", version: getCliVersion() || ""}, - acceptRemoteConnections: !!argv.acceptRemoteConnections, - networkAddressCount: networkAddresses.length, - }); - } - const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph"); const {serve: serverServe} = await import("@ui5/server"); const {getSslCertificate} = await import("@ui5/server/internal/sslUtil"); @@ -204,23 +156,6 @@ serve.handler = async function(argv) { }); } - if (useBanner) { - // Fill in the project + framework section now that the graph has - // resolved — the rest of the header still shows placeholders until - // the server is bound. - const rootProject = graph.getRoot(); - const frameworkName = rootProject.getFrameworkName?.(); - const frameworkVersion = rootProject.getFrameworkVersion?.(); - banner.setProject({ - name: rootProject.getName(), - type: rootProject.getType(), - version: rootProject.getVersion(), - framework: frameworkName ? - {name: frameworkName, version: frameworkVersion} : - null, - }); - } - let port = argv.port; let changePortIfInUse = false; @@ -280,28 +215,6 @@ serve.handler = async function(argv) { const protocol = h2 ? "https" : "http"; let browserUrl = protocol + "://localhost:" + actualPort; - if (useBanner) { - banner.setUrls({ - local: browserUrl, - network: networkAddresses.length ? - networkAddresses.map((addr) => protocol + "://" + addr + ":" + actualPort) : - undefined, - }); - } else { - if (argv.acceptRemoteConnections) { - process.stderr.write("\n"); - for (const line of REMOTE_CONNECTIONS_WARNING_LINES) { - process.stderr.write(line); - process.stderr.write("\n"); - } - process.stderr.write("\n"); - } - process.stdout.write("Server started"); - process.stdout.write("\n"); - process.stdout.write("URL: " + browserUrl); - process.stdout.write("\n"); - } - if (argv.open !== undefined) { if (typeof argv.open === "string") { let relPath = argv.open || "/"; diff --git a/packages/cli/lib/cli/middlewares/logger.js b/packages/cli/lib/cli/middlewares/logger.js index ec9536d4a71..0d9ab95fb72 100644 --- a/packages/cli/lib/cli/middlewares/logger.js +++ b/packages/cli/lib/cli/middlewares/logger.js @@ -1,6 +1,13 @@ -import {setLogLevel, isLogLevelEnabled, getLogger} from "@ui5/logger"; +import process from "node:process"; +import {setLogLevel, isLogLevelEnabled, getLogger, getLogLevel} from "@ui5/logger"; import ConsoleWriter from "@ui5/logger/writers/Console"; -import {getVersionWithLocation} from "../version.js"; +import {getVersion, getVersionWithLocation} from "../version.js"; + +// Log levels that the interactive writer cannot represent (firehose verbose +// logging, or user-requested silence). Fall back to the plain Console writer +// in those cases. +const NON_INTERACTIVE_LEVELS = new Set(["perf", "verbose", "silly", "silent"]); + /** * Logger middleware to enable logging capabilities * @@ -23,8 +30,27 @@ export async function initLogger(argv) { setLogLevel(argv.loglevel); } - // Initialize writer - ConsoleWriter.init(); + const commandName = Array.isArray(argv._) ? argv._[0] : null; + const useInteractive = + commandName === "serve" && + process.stderr.isTTY === true && + !NON_INTERACTIVE_LEVELS.has(getLogLevel()); + + if (useInteractive) { + const {default: InteractiveConsole} = await import("@ui5/logger/writers/InteractiveConsole"); + InteractiveConsole.init(); + } else { + ConsoleWriter.init(); + } + + // Announce the CLI as soon as a writer is attached, so its header region + // (or scrollback line) shows the tool identity before any command work + // starts. + process.emit("ui5.tool-info", { + name: "UI5 CLI", + version: getVersion() || "", + }); + if (isLogLevelEnabled("verbose")) { const log = getLogger("cli:middlewares:base"); log.verbose(`using @ui5/cli version ${getVersionWithLocation()}`); diff --git a/packages/cli/lib/serve/render.js b/packages/cli/lib/serve/render.js deleted file mode 100644 index b58f125276c..00000000000 --- a/packages/cli/lib/serve/render.js +++ /dev/null @@ -1,174 +0,0 @@ -import chalk from "chalk"; -import figures from "figures"; -import prettyHrtime from "pretty-hrtime"; -import {STATES} from "./state.js"; -import {REMOTE_CONNECTIONS_WARNING_LINES} from "./remoteConnectionsWarning.js"; - -const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"]; -const STATE_LABEL_WIDTH = "building".length; -const pad = (s) => s.padEnd(STATE_LABEL_WIDTH); - -// COLORFGBG is set by many terminals (xterm, konsole, rxvt, iTerm2, …) as -// ";" with ANSI color indices. Indices 0-6 and 8 are conventionally -// dark backgrounds. Apple Terminal, VS Code, and Windows Terminal don't set -// this — we fall back to the light-background palette in that case. -function isDarkTerminalBackground() { - const v = process.env.COLORFGBG; - if (!v) { - return false; - } - const bg = parseInt(v.split(";").pop(), 10); - if (Number.isNaN(bg)) { - return false; - } - return bg < 7 || bg === 8; -} - -const DARK_MODE = isDarkTerminalBackground(); -const BRAND_HEX = DARK_MODE ? "#FF5A37" : "#1873B4"; -const ACCENT_HEX = DARK_MODE ? "#FFA42C" : "#53B8DE"; - -const brand = (text) => chalk.bold.hex(BRAND_HEX)(text); -const accent = (text) => chalk.hex(ACCENT_HEX)(text); -const accentBold = (text) => chalk.bold.hex(ACCENT_HEX)(text); -const arrow = accent(figures.pointer); -const placeholder = (text) => chalk.dim.italic(text); - -// Align continuation lines (additional network URLs / extra placeholders) -// under the first URL slot. The arrow, single space, and "Network: " label -// are all fixed-width. -const NETWORK_INDENT = " ".repeat(`${figures.pointer} ${"Network:"} `.length); - -// Render the static header — printed once at startup. Returns an array of -// lines so callers can count rows without parsing ANSI. -// -// `urls`, `project`, and `framework` may be `null`/`undefined` to indicate -// that the value isn't known yet. The header renders a dim placeholder for -// each unknown section so the layout is stable across incremental updates. -export function renderHeader({ - brand: brandInfo, - urls, - acceptRemoteConnections, - networkAddressCount = 0, - project, - framework, -}) { - const lines = []; - lines.push(""); - const brandVersion = brandInfo?.version ? chalk.dim("v" + brandInfo.version) : ""; - lines.push(`${brand(brandInfo?.name || "UI5 CLI")} ${brandVersion}`); - lines.push(""); - - const localStr = urls?.local ? - accent(urls.local) : - placeholder("binding…"); - lines.push(`${arrow} ${accentBold("Local:")} ${localStr}`); - - const networkUrls = urls?.network && urls.network.length ? urls.network : null; - if (networkUrls) { - lines.push(`${arrow} ${accentBold("Network:")} ${accent(networkUrls[0])}`); - for (let i = 1; i < networkUrls.length; i++) { - lines.push(`${NETWORK_INDENT}${accent(networkUrls[i])}`); - } - } else if (!acceptRemoteConnections) { - // `--accept-remote-connections` wasn't passed — the network bind won't - // happen, so render the final hint now instead of a placeholder. - lines.push(`${arrow} ${accentBold("Network:")} ` + - chalk.dim("use --accept-remote-connections to expose")); - } else { - // Reserve as many placeholder rows as setUrls() is expected to deliver - // (at least one, so the section is always visible even before the host's - // interfaces are known) — keeps the live region from re-flowing when the - // real URLs arrive. - const placeholderRows = Math.max(1, networkAddressCount); - lines.push(`${arrow} ${accentBold("Network:")} ${placeholder("binding…")}`); - for (let i = 1; i < placeholderRows; i++) { - lines.push(`${NETWORK_INDENT}${placeholder("binding…")}`); - } - } - - // `acceptRemoteConnections` reflects user intent (the flag was passed), so - // the warning is meaningful from the first paint — well before the server - // has actually bound to the network. - if (acceptRemoteConnections) { - lines.push(""); - for (const line of REMOTE_CONNECTIONS_WARNING_LINES) { - lines.push(line); - } - } - - lines.push(""); - - if (project) { - const projectType = project.type ? chalk.dim(`(${project.type})`) : ""; - const projectVersion = project.version ? chalk.dim("v" + project.version) : ""; - lines.push(`${chalk.dim("Project")} ${chalk.bold(project.name)}` + - (projectType ? ` ${projectType}` : "") + - (projectVersion ? ` ${projectVersion}` : "")); - - if (framework && framework.name) { - const frameworkVersion = framework.version ? ` ${framework.version}` : ""; - lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name + frameworkVersion)}`); - } else { - // Render an explicit placeholder so the Framework row exists in - // every frame — the live region's row count must not change once - // setProject() resolves, otherwise the status line shifts. - lines.push(`${chalk.dim("Framework")} ${placeholder("(none)")}`); - } - } else { - lines.push(`${chalk.dim("Project")} ${placeholder("resolving…")}`); - lines.push(`${chalk.dim("Framework")} ${placeholder("resolving…")}`); - } - - return lines; -} - -// Render the status line as a single string (no trailing newline). -export function renderStatusLine(state, layout = {}) { - const label = `${chalk.dim("Status")} `; - switch (state.state) { - case STATES.INITIAL: - return `${label}${chalk.dim(figures.circle)} ${chalk.dim(pad("starting"))}`; - case STATES.READY: { - let suffix = ""; - if (state.lastBuildHrtime) { - suffix = ` ${chalk.dim("·")} ${chalk.dim("Time elapsed: " + prettyHrtime(state.lastBuildHrtime))}`; - } - return `${label}${chalk.green(figures.bullet)} ${chalk.green(pad("ready"))}${suffix}`; - } - case STATES.STALE: - return `${label}${chalk.yellow(figures.circle)} ${chalk.yellow(pad("stale"))} ` + - `${chalk.dim("· files changed, rebuild on next request")}`; - case STATES.BUILDING: { - const frame = SPINNER_FRAMES[state.spinFrame % SPINNER_FRAMES.length]; - const parts = [ - `${chalk.yellow(frame)} ${chalk.yellow(pad("building"))}`, - ]; - if (state.totalProjects > 0 && state.currentProjectIndex > 0) { - const counterWidth = String(state.totalProjects).length; - const counter = `${String(state.currentProjectIndex).padStart(counterWidth)}` + - `/${state.totalProjects} projects`; - parts.push(chalk.dim("·"), chalk.dim(counter)); - } - if (state.currentProjectName) { - const padded = layout.projectNameWidth ? - state.currentProjectName.padEnd(layout.projectNameWidth) : - state.currentProjectName; - parts.push(chalk.dim("·"), chalk.bold(padded)); - } - if (state.currentTaskName) { - const padded = layout.taskNameWidth ? - state.currentTaskName.padEnd(layout.taskNameWidth) : - state.currentTaskName; - parts.push(chalk.dim("·"), chalk.dim(padded)); - } - return `${label}${parts.join(" ")}`; - } - case STATES.ERROR: { - const msg = state.errorMessage ? ` · ${state.errorMessage}` : ""; - return `${label}${chalk.red(figures.cross)} ${chalk.red(pad("error"))}${chalk.dim(msg)}`; - } - default: - return label; - } -} diff --git a/packages/cli/lib/serve/state.js b/packages/cli/lib/serve/state.js deleted file mode 100644 index f431be9773f..00000000000 --- a/packages/cli/lib/serve/state.js +++ /dev/null @@ -1,49 +0,0 @@ -// Pure state holder for the live banner. Mutated directly by Banner setters -// and event handlers, each of which calls `Banner.prototype.#render` after -// updating the fields it owns. - -export const STATES = Object.freeze({ - INITIAL: "initial", - READY: "ready", - STALE: "stale", - BUILDING: "building", - ERROR: "error", -}); - -export function createInitialState() { - return { - state: STATES.INITIAL, - // Header sections — filled in incrementally by Banner setters so the - // header can paint a skeleton (with placeholders) before all the data - // is known. `null` means "not yet known"; the renderer shows a - // placeholder in that case. - brand: null, - urls: null, - project: null, - framework: null, - acceptRemoteConnections: false, - // Expected number of network address lines, supplied up front by the - // CLI so the initial paint reserves the same number of rows that - // setUrls() will later fill in. Keeps the live region from re-flowing - // when the real URLs arrive. - networkAddressCount: 0, - // During `building`: current project counter (1-based) and total projects. - // Both are reset by `ui5.build-metadata`. - currentProjectIndex: 0, - totalProjects: 0, - currentProjectName: "", - currentTaskName: "", - // Names of projects collected via `serve-stale` payloads — used to label - // the stale state if/when the renderer wants to. - changedProjects: [], - // Frame counter for the spinner (incremented by the tick loop). - spinFrame: 0, - // Most recent error captured by `serve-error`. - errorMessage: "", - // Duration of the most recent successful build, captured from the - // `serve-build-done` event as a [seconds, nanoseconds] tuple — the same - // shape that pretty-hrtime consumes, so the renderer can hand it - // straight through. - lastBuildHrtime: null, - }; -} diff --git a/packages/cli/package.json b/packages/cli/package.json index 1b298f9d8d0..3229b04d2b2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -61,11 +61,9 @@ "figures": "^6.1.0", "import-local": "^3.2.0", "js-yaml": "^4.2.0", - "log-update": "^7.2.0", "open": "^11.0.0", "pretty-hrtime": "^1.0.3", "semver": "^7.8.5", - "slice-ansi": "^5.0.0", "update-notifier": "^7.3.1", "yargs": "^18.0.0" }, diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js index ed7753ef10f..7cd5f0fb383 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -1,10 +1,7 @@ import path from "node:path"; -import os from "node:os"; import test from "ava"; import sinon from "sinon"; import esmock from "esmock"; -import chalk from "chalk"; -import figures from "figures"; import yargs from "yargs"; function getDefaultArgv() { @@ -38,9 +35,16 @@ function getDefaultArgv() { test.beforeEach(async (t) => { t.context.argv = getDefaultArgv(); + // server.serve is the CLI-facing synchronization point now that the handler + // no longer writes "Server started" to stdout. Test cases await `serverServed` + // to know the handler has finished setting up the server. + t.context.handlerReadyResolvers = Promise.withResolvers(); + t.context.handlerReady = t.context.handlerReadyResolvers.promise; + t.context.server = { serve: sinon.stub().callsFake((graph, config, errorCallback) => { t.context.serverErrorCallback = errorCallback; + t.context.handlerReadyResolvers.resolve(); return { h2: false, port: 8080 @@ -65,16 +69,14 @@ test.beforeEach(async (t) => { graphFromPackageDependencies: sinon.stub().resolves(t.context.fakeGraph) }; - + // Capture stray writes to stderr/stdout so failing assertions surface the + // actual output instead of ava's timeout diagnostics. t.context.consoleOutput = ""; t.context.processStderrWrite = sinon.stub(process.stderr, "write").callsFake((message) => { t.context.consoleOutput += message; }); - t.context.handlerReady = new Promise((resolve) => { - t.context.processStdoutWrite = sinon.stub(process.stdout, "write").callsFake((message) => { - t.context.consoleOutput += message; - resolve(); - }); + t.context.processStdoutWrite = sinon.stub(process.stdout, "write").callsFake((message) => { + t.context.consoleOutput += message; }); t.context.open = sinon.stub(); @@ -92,23 +94,6 @@ test.afterEach.always((t) => { esmock.purge(t.context.serve); }); -// Override a (possibly absent) data property on `target` for the duration of the -// test. `sinon.replace` can't be used here because `process.stdout.isTTY` and -// `process.env.UI5_LOG_LVL` may not exist as own properties under AVA, so we -// drive `Object.defineProperty`/`delete` directly and let `t.teardown` restore. -function overrideProperty(t, target, prop, value) { - const prevDescriptor = Object.getOwnPropertyDescriptor(target, prop); - Object.defineProperty(target, prop, - {value, configurable: true, writable: true, enumerable: true}); - t.teardown(() => { - if (prevDescriptor) { - Object.defineProperty(target, prop, prevDescriptor); - } else { - delete target[prop]; - } - }); -} - test.serial("ui5 serve: default", async (t) => { const {argv, serve, graph, server, fakeGraph} = t.context; @@ -123,10 +108,6 @@ test.serial("ui5 serve: default", async (t) => { snapshotCache: "Default", }]); - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - t.is(server.serve.callCount, 1); t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ fakeGraph, @@ -155,9 +136,10 @@ test.serial("ui5 serve --h2", async (t) => { cert: "random-cert" }); - server.serve.returns({ - h2: true, - port: 8443 + server.serve.callsFake((graph, config, errorCallback) => { + t.context.serverErrorCallback = errorCallback; + t.context.handlerReadyResolvers.resolve(); + return {h2: true, port: 8443}; }); argv.h2 = true; @@ -167,15 +149,6 @@ test.serial("ui5 serve --h2", async (t) => { t.is(graph.graphFromStaticFile.callCount, 0); t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: https://localhost:8443 -`); t.is(server.serve.callCount, 1); t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ @@ -203,36 +176,13 @@ URL: https://localhost:8443 }); test.serial("ui5 serve --accept-remote-connections", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, server, fakeGraph} = t.context; argv.acceptRemoteConnections = true; serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, ` -${chalk.bold.yellow(`${figures.warning} This server is accepting connections from all hosts on your network`)} -${chalk.dim.underline("Please Note:")} -${chalk.dim(`${figures.pointerSmall} `) + - chalk.dim.bold("This server is intended for development purposes only. Do not use it in production.")} -${chalk.dim(`${figures.pointerSmall} ` + - "Vulnerable (custom-)middleware can pose a threat to your system when exposed to the network.")} -${chalk.dim(`${figures.pointerSmall} ` + - "The use of proxy-middleware with preconfigured credentials might enable unauthorized access")} -${chalk.dim(" to a target system for third parties on your network.")} - -Server started -URL: http://localhost:8080 -`); - t.is(server.serve.callCount, 1); t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ fakeGraph, @@ -253,7 +203,7 @@ URL: http://localhost:8080 }); test.serial("ui5 serve --open", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve} = t.context; const openCalled = new Promise((resolve) => { t.context.open.callsFake(resolve); @@ -264,36 +214,6 @@ test.serial("ui5 serve --open", async (t) => { serve.handler(argv); await openCalled; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); - t.is(t.context.open.callCount, 1); t.deepEqual(t.context.open.getCall(0).args, [ "http://localhost:8080/index.html" @@ -301,7 +221,7 @@ URL: http://localhost:8080 }); test.serial("ui5 serve --open (opens default url)", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve} = t.context; const openCalled = new Promise((resolve) => { t.context.open.callsFake(resolve); @@ -312,36 +232,6 @@ test.serial("ui5 serve --open (opens default url)", async (t) => { serve.handler(argv); await openCalled; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); - t.is(t.context.open.callCount, 1); t.deepEqual(t.context.open.getCall(0).args, [ "http://localhost:8080" @@ -349,7 +239,7 @@ URL: http://localhost:8080 }); test.serial("ui5 serve --config", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, graph} = t.context; const fakePath = path.join("/", "path", "to", "ui5.yaml"); argv.config = fakePath; @@ -357,39 +247,15 @@ test.serial("ui5 serve --config", async (t) => { serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: fakePath, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, snapshotCache: "Default", }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); }); test.serial("ui5 serve --dependency-definition", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, graph} = t.context; const fakePath = path.join("/", "path", "to", "dependencies.yaml"); argv.dependencyDefinition = fakePath; @@ -403,32 +269,10 @@ test.serial("ui5 serve --dependency-definition", async (t) => { filePath: fakePath, versionOverride: undefined, snapshotCache: "Default", rootConfigPath: undefined }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true - } - ]); }); test.serial("ui5 serve --dependency-definition / --config", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, graph} = t.context; const fakeDependenciesPath = path.join("/", "path", "to", "dependencies.yaml"); argv.dependencyDefinition = fakeDependenciesPath; @@ -439,194 +283,75 @@ test.serial("ui5 serve --dependency-definition / --config", async (t) => { serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromPackageDependencies.callCount, 0); t.is(graph.graphFromStaticFile.callCount, 1); t.deepEqual(graph.graphFromStaticFile.getCall(0).args, [{ filePath: fakeDependenciesPath, versionOverride: undefined, snapshotCache: "Default", rootConfigPath: fakeConfigPath }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true - } - ]); }); test.serial("ui5 serve --framework-version", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, graph} = t.context; argv.frameworkVersion = "1.234.5"; serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: "1.234.5", workspaceConfigPath: undefined, workspaceName: undefined, snapshotCache: "Default", }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); }); test.serial("ui5 serve --snapshotCache", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, graph} = t.context; argv.snapshotCache = "Force"; serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: undefined, snapshotCache: "Force", }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); }); test.serial("ui5 serve --workspace", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, graph} = t.context; argv.workspace = "dolphin"; serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: "dolphin", snapshotCache: "Default", }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); }); test.serial("ui5 serve --no-workspace", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, graph} = t.context; argv.workspace = false; serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: undefined, workspaceName: null, snapshotCache: "Default", }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); }); test.serial("ui5 serve --workspace-config", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, graph} = t.context; const fakePath = path.join("/", "path", "to", "ui5-workspace.yaml"); argv.workspaceConfig = fakePath; @@ -634,152 +359,44 @@ test.serial("ui5 serve --workspace-config", async (t) => { serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ rootConfigPath: undefined, versionOverride: undefined, workspaceConfigPath: fakePath, workspaceName: undefined, snapshotCache: "Default", }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); }); test.serial("ui5 serve --sap-csp-policies", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, server} = t.context; argv.sapCspPolicies = true; serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: true, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); + t.is(server.serve.getCall(0).args[1].sendSAPTargetCSP, true); }); test.serial("ui5 serve --serve-csp-reports", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, server} = t.context; argv.serveCspReports = true; serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: true, - simpleIndex: false, - liveReload: true, - } - ]); + t.is(server.serve.getCall(0).args[1].serveCSPReports, true); }); test.serial("ui5 serve --simple-index", async (t) => { - const {argv, serve, graph, server, fakeGraph} = t.context; + const {argv, serve, server} = t.context; argv.simpleIndex = true; serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:8080 -`); - - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: true, - h2: false, - key: undefined, - port: 8080, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: true, - liveReload: true, - } - ]); + t.is(server.serve.getCall(0).args[1].simpleIndex, true); }); test.serial("ui5 serve --no-live-reload", async (t) => { @@ -836,53 +453,28 @@ test.serial("ui5 serve --live-reload overrides ui5.yaml liveReload setting", asy }); test.serial("ui5 serve with ui5.yaml port setting", async (t) => { - const {argv, serve, graph, server, fakeGraph, getServerSettings} = t.context; + const {argv, serve, server, getServerSettings} = t.context; getServerSettings.returns({ httpPort: 3333 }); - server.serve.returns({ - h2: false, - port: 3333 + server.serve.callsFake((graph, config, errorCallback) => { + t.context.serverErrorCallback = errorCallback; + t.context.handlerReadyResolvers.resolve(); + return {h2: false, port: 3333}; }); serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: http://localhost:3333 -`); - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - cert: undefined, - changePortIfInUse: false, - h2: false, - key: undefined, - port: 3333, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); + t.is(server.serve.getCall(0).args[1].port, 3333); + t.is(server.serve.getCall(0).args[1].changePortIfInUse, false); }); test.serial("ui5 serve --h2 with ui5.yaml port setting", async (t) => { - const {argv, serve, graph, server, fakeGraph, sslUtil, getServerSettings} = t.context; + const {argv, serve, server, sslUtil, getServerSettings} = t.context; sslUtil.getSslCertificate.resolves({ key: "random-key", @@ -893,9 +485,10 @@ test.serial("ui5 serve --h2 with ui5.yaml port setting", async (t) => { httpsPort: 4444 }); - server.serve.returns({ - h2: true, - port: 4444 + server.serve.callsFake((graph, config, errorCallback) => { + t.context.serverErrorCallback = errorCallback; + t.context.handlerReadyResolvers.resolve(); + return {h2: true, port: 4444}; }); argv.h2 = true; @@ -903,45 +496,14 @@ test.serial("ui5 serve --h2 with ui5.yaml port setting", async (t) => { serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: https://localhost:4444 -`); - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - changePortIfInUse: false, - h2: true, - key: "random-key", - cert: "random-cert", - port: 4444, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); - - t.is(sslUtil.getSslCertificate.callCount, 1); - t.deepEqual(sslUtil.getSslCertificate.getCall(0).args, [ - "/home/.ui5/server/server.key", - "/home/.ui5/server/server.crt" - ]); + t.is(server.serve.getCall(0).args[1].port, 4444); + t.is(server.serve.getCall(0).args[1].changePortIfInUse, false); + t.is(server.serve.getCall(0).args[1].h2, true); }); test.serial("ui5 serve --h2 with ui5.yaml port setting and port CLI argument", async (t) => { - const {argv, serve, graph, server, fakeGraph, sslUtil, getServerSettings} = t.context; + const {argv, serve, server, sslUtil, getServerSettings} = t.context; sslUtil.getSslCertificate.resolves({ key: "random-key", @@ -952,9 +514,10 @@ test.serial("ui5 serve --h2 with ui5.yaml port setting and port CLI argument", a httpsPort: 4444 }); - server.serve.returns({ - h2: true, - port: 5555 + server.serve.callsFake((graph, config, errorCallback) => { + t.context.serverErrorCallback = errorCallback; + t.context.handlerReadyResolvers.resolve(); + return {h2: true, port: 5555}; }); argv.h2 = true; @@ -963,41 +526,9 @@ test.serial("ui5 serve --h2 with ui5.yaml port setting and port CLI argument", a serve.handler(argv); await t.context.handlerReady; - t.is(graph.graphFromStaticFile.callCount, 0); - t.is(graph.graphFromPackageDependencies.callCount, 1); - t.deepEqual(graph.graphFromPackageDependencies.getCall(0).args, [{ - rootConfigPath: undefined, versionOverride: undefined, - workspaceConfigPath: undefined, workspaceName: undefined, - snapshotCache: "Default", - }]); - - t.is(t.context.consoleOutput, `Server started -URL: https://localhost:5555 -`); - t.is(server.serve.callCount, 1); - t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [ - fakeGraph, - { - acceptRemoteConnections: false, - cache: undefined, - changePortIfInUse: false, - h2: true, - key: "random-key", - cert: "random-cert", - port: 5555, - sendSAPTargetCSP: false, - serveCSPReports: false, - simpleIndex: false, - liveReload: true, - } - ]); - - t.is(sslUtil.getSslCertificate.callCount, 1); - t.deepEqual(sslUtil.getSslCertificate.getCall(0).args, [ - "/home/.ui5/server/server.key", - "/home/.ui5/server/server.crt" - ]); + t.is(server.serve.getCall(0).args[1].port, 5555); + t.is(server.serve.getCall(0).args[1].changePortIfInUse, false); }); test.serial("ui5 serve: Error callback propagates to handler", async (t) => { @@ -1050,236 +581,11 @@ test.serial("ui5 serve builder: --cache-mode coerce logs deprecation warning", a const cli = yargs().exitProcess(false); serve.builder(cli); - const argv = await cli.parseAsync(["--cache-mode", "Force"]); + t.is(argv.cacheMode, "Force"); t.is(logWarn.callCount, 1, "log.warn got called once"); t.regex(logWarn.getCall(0).args[0], /'--cache-mode' is renamed to '--snapshot-cache'/); esmock.purge(serve); }); - -test.serial("banner gate: activates when stdout is a TTY and log level is info", async (t) => { - const {argv, graph, server, sslUtil, open} = t.context; - const bannerSetProject = sinon.stub(); - const {promise: urlsCalled, resolve: signalUrlsCalled} = Promise.withResolvers(); - const bannerSetUrls = sinon.stub().callsFake(() => signalUrlsCalled()); - const bannerInstance = { - setProject: bannerSetProject, - setUrls: bannerSetUrls, - stop: sinon.stub(), - }; - const bannerObserve = sinon.stub().returns(bannerInstance); - const consoleStop = sinon.stub(); - const consoleInit = sinon.stub(); - - // Project metadata accessors used by serve.js when wiring the banner. - t.context.fakeGraph.getRoot = () => ({ - getServerSettings: t.context.getServerSettings, - getName: () => "my.app", - getType: () => "application", - getVersion: () => "1.0.0", - getFrameworkName: () => "SAPUI5", - getFrameworkVersion: () => "1.0.0", - }); - - overrideProperty(t, process.stdout, "isTTY", true); - const serve = await esmock.p("../../../../lib/cli/commands/serve.js", { - "@ui5/server": server, - "@ui5/server/internal/sslUtil": sslUtil, - "@ui5/project/graph": graph, - "open": open, - "../../../../lib/serve/Banner.js": {default: {observe: bannerObserve}}, - "@ui5/logger/writers/Console": {default: {stop: consoleStop, init: consoleInit}}, - }); - - serve.handler(argv).catch(() => {/* may reject on shutdown — ignore here */}); - await urlsCalled; - - t.is(consoleStop.callCount, 1, "Console.stop was called before the build starts"); - t.is(bannerObserve.callCount, 1, "Banner.observe was called"); - const observeOpts = bannerObserve.getCall(0).args[0]; - t.is(observeOpts.brand.name, "UI5 CLI", "brand passed to observe up front"); - t.false(observeOpts.acceptRemoteConnections, - "acceptRemoteConnections is fixed at observe time from argv"); - - t.is(bannerSetProject.callCount, 1, "banner.setProject was called after graph resolved"); - const projectInfo = bannerSetProject.getCall(0).args[0]; - t.is(projectInfo.name, "my.app"); - t.is(projectInfo.type, "application"); - t.is(projectInfo.framework.name, "SAPUI5"); - - t.is(bannerSetUrls.callCount, 1, "banner.setUrls was called after serverServe resolved"); - const urlsInfo = bannerSetUrls.getCall(0).args[0]; - t.is(urlsInfo.local, "http://localhost:8080"); - - esmock.purge(serve); -}); - -test.serial("banner gate: passes network address from os.networkInterfaces to banner urls.network", async (t) => { - const {argv, graph, server, sslUtil, open} = t.context; - const {promise: urlsCalled, resolve: signalUrlsCalled} = Promise.withResolvers(); - const bannerSetUrls = sinon.stub().callsFake(() => signalUrlsCalled()); - const bannerInstance = { - setProject: sinon.stub(), - setUrls: bannerSetUrls, - stop: sinon.stub(), - }; - const bannerObserve = sinon.stub().returns(bannerInstance); - const consoleStop = sinon.stub(); - const consoleInit = sinon.stub(); - - // Simulate a host that exposes one non-internal IPv4 interface — the CLI - // is now responsible for discovering it; @ui5/server no longer reports it. - const osMock = { - default: { - ...os, - networkInterfaces: () => ({ - eth0: [{family: "IPv4", internal: false, address: "0.0.0.0"}], - }), - }, - }; - - argv.acceptRemoteConnections = true; - - t.context.fakeGraph.getRoot = () => ({ - getServerSettings: t.context.getServerSettings, - getName: () => "my.app", - getType: () => "application", - getVersion: () => "1.0.0", - getFrameworkName: () => "SAPUI5", - getFrameworkVersion: () => "1.0.0", - }); - - overrideProperty(t, process.stdout, "isTTY", true); - const serve = await esmock.p("../../../../lib/cli/commands/serve.js", { - "@ui5/server": server, - "@ui5/server/internal/sslUtil": sslUtil, - "@ui5/project/graph": graph, - "open": open, - "node:os": osMock, - "../../../../lib/serve/Banner.js": {default: {observe: bannerObserve}}, - "@ui5/logger/writers/Console": {default: {stop: consoleStop, init: consoleInit}}, - }); - - serve.handler(argv).catch(() => {/* may reject on shutdown — ignore here */}); - await urlsCalled; - - t.true(bannerObserve.getCall(0).args[0].acceptRemoteConnections, - "observe received the remote-connections flag"); - t.is(bannerSetUrls.callCount, 1); - const urlsInfo = bannerSetUrls.getCall(0).args[0]; - t.is(urlsInfo.local, "http://localhost:8080"); - t.deepEqual(urlsInfo.network, ["http://0.0.0.0:8080"], - "network URL is built from the IP discovered by the CLI"); - - esmock.purge(serve); -}); - -test.serial("banner gate: urls.network is undefined when no external IPv4 interface is available", async (t) => { - const {argv, graph, server, sslUtil, open} = t.context; - const {promise: urlsCalled, resolve: signalUrlsCalled} = Promise.withResolvers(); - const bannerSetUrls = sinon.stub().callsFake(() => signalUrlsCalled()); - const bannerInstance = { - setProject: sinon.stub(), - setUrls: bannerSetUrls, - stop: sinon.stub(), - }; - const bannerObserve = sinon.stub().returns(bannerInstance); - const consoleStop = sinon.stub(); - const consoleInit = sinon.stub(); - - // User asked for remote connections but the host has no suitable IPv4 - // interface (only loopback) — network URL must be omitted. - const osMock = { - default: { - ...os, - networkInterfaces: () => ({ - lo: [{family: "IPv4", internal: true, address: "127.0.0.1"}], - }), - }, - }; - - argv.acceptRemoteConnections = true; - - t.context.fakeGraph.getRoot = () => ({ - getServerSettings: t.context.getServerSettings, - getName: () => "my.app", - getType: () => "application", - getVersion: () => "1.0.0", - getFrameworkName: () => "SAPUI5", - getFrameworkVersion: () => "1.0.0", - }); - - overrideProperty(t, process.stdout, "isTTY", true); - const serve = await esmock.p("../../../../lib/cli/commands/serve.js", { - "@ui5/server": server, - "@ui5/server/internal/sslUtil": sslUtil, - "@ui5/project/graph": graph, - "open": open, - "node:os": osMock, - "../../../../lib/serve/Banner.js": {default: {observe: bannerObserve}}, - "@ui5/logger/writers/Console": {default: {stop: consoleStop, init: consoleInit}}, - }); - - serve.handler(argv).catch(() => {/* may reject on shutdown — ignore here */}); - await urlsCalled; - - t.is(bannerSetUrls.callCount, 1); - const urlsInfo = bannerSetUrls.getCall(0).args[0]; - t.is(urlsInfo.network, undefined, - "network URL is omitted when no external IPv4 is available"); - t.true(bannerObserve.getCall(0).args[0].acceptRemoteConnections, - "observe received the remote-connections flag"); - - esmock.purge(serve); -}); - -test.serial("banner gate: falls back to plain output when log level is verbose", async (t) => { - const {argv, graph, server, sslUtil, open} = t.context; - const bannerObserve = sinon.stub().returns({start: sinon.stub(), stop: sinon.stub()}); - const consoleStop = sinon.stub(); - - overrideProperty(t, process.stdout, "isTTY", true); - overrideProperty(t, process.env, "UI5_LOG_LVL", "verbose"); - const serve = await esmock.p("../../../../lib/cli/commands/serve.js", { - "@ui5/server": server, - "@ui5/server/internal/sslUtil": sslUtil, - "@ui5/project/graph": graph, - "open": open, - "../../../../lib/serve/Banner.js": {default: {observe: bannerObserve}}, - "@ui5/logger/writers/Console": {default: {stop: consoleStop, init: sinon.stub()}}, - }); - - serve.handler(argv).catch(() => {/* ignore */}); - await t.context.handlerReady; - - t.is(bannerObserve.callCount, 0, "Banner.observe was NOT called when log level is verbose"); - t.is(consoleStop.callCount, 0, "Console writer stays attached when log level is verbose"); - t.regex(t.context.consoleOutput, /Server started/); - t.regex(t.context.consoleOutput, /URL: http:\/\/localhost:8080/); - - esmock.purge(serve); -}); - -test.serial("banner gate: falls back to plain output when stdout is not a TTY", async (t) => { - const {argv, graph, server, sslUtil, open} = t.context; - const bannerObserve = sinon.stub().returns({start: sinon.stub(), stop: sinon.stub()}); - - overrideProperty(t, process.stdout, "isTTY", false); - const serve = await esmock.p("../../../../lib/cli/commands/serve.js", { - "@ui5/server": server, - "@ui5/server/internal/sslUtil": sslUtil, - "@ui5/project/graph": graph, - "open": open, - "../../../../lib/serve/Banner.js": {default: {observe: bannerObserve}}, - }); - - serve.handler(argv).catch(() => {/* ignore */}); - await t.context.handlerReady; - - t.is(bannerObserve.callCount, 0, "Banner.observe was NOT called without a TTY"); - t.regex(t.context.consoleOutput, /Server started/); - - esmock.purge(serve); -}); diff --git a/packages/cli/test/lib/serve/Banner.js b/packages/cli/test/lib/serve/Banner.js deleted file mode 100644 index 694afaf7a19..00000000000 --- a/packages/cli/test/lib/serve/Banner.js +++ /dev/null @@ -1,632 +0,0 @@ -import test from "ava"; -import sinon from "sinon"; -import {EventEmitter} from "node:events"; -import stripAnsi from "strip-ansi"; -import figures from "figures"; - -import Banner from "../../../lib/serve/Banner.js"; -import {STATES} from "../../../lib/serve/state.js"; - -function createStubStdout() { - const stub = new EventEmitter(); - stub.columns = 200; - stub.writes = []; - stub.write = (chunk) => { - stub.writes.push(chunk); - return true; - }; - return stub; -} - -// Spin up a fully-populated banner the way production does it: observe() first, -// then setProject() + setUrls(). Mirrors serve.js's call sequence so the tests -// drive Banner through its real public API. The banner starts in the INITIAL -// state — tests that need a particular runtime state emit the matching -// ui5.serve-status event themselves. -function createBanner(stdout) { - const banner = Banner.observe({ - stdout, - brand: {name: "UI5 CLI", version: "1.0.0"}, - acceptRemoteConnections: false, - }); - banner.setProject({ - name: "my.app", - type: "application", - version: "1.0.0", - framework: {name: "SAPUI5", version: "1.0.0"}, - }); - banner.setUrls({local: "http://localhost:8080"}); - return banner; -} - -test.serial("Banner.observe + setters: prints header + initial ready status", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.serve-status", {status: "serve-ready"}); - - const output = stripAnsi(stdout.writes.join("")); - t.regex(output, /UI5 CLI v1\.0\.0/); - t.regex(output, /Local:\s+http:\/\/localhost:8080/); - t.regex(output, /Project\s+my\.app/); - t.regex(output, new RegExp(`Status\\s+${figures.bullet}\\s+ready`)); - - t.is(banner._getStateForTest().state, STATES.READY); - banner.stop(); -}); - -test.serial("Banner reacts to serve-stale event", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - - process.emit("ui5.serve-status", { - status: "serve-stale", - changedProjects: ["my.app"], - }); - - t.is(banner._getStateForTest().state, STATES.STALE); - const tail = stripAnsi(stdout.writes.slice(-5).join("")); - t.regex(tail, /stale/); - banner.stop(); -}); - -test.serial("Banner reacts to serve-building, then serve-build-done", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - - process.emit("ui5.serve-status", {status: "serve-building"}); - t.is(banner._getStateForTest().state, STATES.BUILDING); - - process.emit("ui5.serve-status", {status: "serve-build-done", hrtime: [0, 50000000]}); - t.is(banner._getStateForTest().state, STATES.READY); - - banner.stop(); -}); - -test.serial("Banner tracks build-metadata and project-build-status updates", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - - process.emit("ui5.serve-status", {status: "serve-building"}); - process.emit("ui5.build-metadata", {projectsToBuild: ["a", "b", "c"]}); - process.emit("ui5.build-status", { - projectName: "b", - projectType: "library", - status: "project-build-start", - }); - process.emit("ui5.project-build-status", { - projectName: "b", - projectType: "library", - taskName: "minify", - status: "task-start", - }); - - const state = banner._getStateForTest(); - t.is(state.totalProjects, 3); - t.is(state.currentProjectIndex, 2); - t.is(state.currentProjectName, "b"); - t.is(state.currentTaskName, "minify"); - - banner.stop(); -}); - -test.serial("Banner enters error state on serve-error", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - - const err = new Error("boom"); - err.stack = "Error: boom\n at "; - process.emit("ui5.serve-status", {status: "serve-error", error: err}); - - t.is(banner._getStateForTest().state, STATES.ERROR); - t.is(banner._getStateForTest().errorMessage, "boom"); - - // The banner intentionally does NOT echo the error via logAbove — - // BuildServer's own log.error and the yargs fail-handler already render - // the message and stack trace, so a third copy would be noise. - const fullOutput = stripAnsi(stdout.writes.join("")); - t.notRegex(fullOutput, /at /, "stack trace is not duplicated via logAbove"); - - // Next build cycle clears error state via the BUILDING transition. - process.emit("ui5.serve-status", {status: "serve-building"}); - t.is(banner._getStateForTest().state, STATES.BUILDING); - - banner.stop(); -}); - -test.serial("Banner routes warn/error log events through logAbove", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - stdout.writes.length = 0; - - process.emit("ui5.log", { - level: "warn", - message: "Heads up", - moduleName: "test:module", - }); - const afterWarn = stripAnsi(stdout.writes.join("")); - t.regex(afterWarn, /Heads up/); - t.regex(afterWarn, /test:module/); - - stdout.writes.length = 0; - process.emit("ui5.log", { - level: "info", - message: "Should not appear", - moduleName: "test:module", - }); - const afterInfo = stripAnsi(stdout.writes.join("")); - t.notRegex(afterInfo, /Should not appear/); - - banner.stop(); -}); - -test.serial("Banner suppresses log events below the configured level", async (t) => { - const {default: Logger} = await import("@ui5/logger/Logger"); - const previousLevel = Logger.getLevel(); - Logger.setLevel("error"); - t.teardown(() => Logger.setLevel(previousLevel)); - - const stdout = createStubStdout(); - const banner = createBanner(stdout); - stdout.writes.length = 0; - - process.emit("ui5.log", { - level: "warn", - message: "Filtered warning", - moduleName: "test:module", - }); - const afterWarn = stripAnsi(stdout.writes.join("")); - t.notRegex(afterWarn, /Filtered warning/, "warn is suppressed when log level is error"); - - process.emit("ui5.log", { - level: "error", - message: "Real failure", - moduleName: "test:module", - }); - const afterError = stripAnsi(stdout.writes.join("")); - t.regex(afterError, /Real failure/, "error still passes through"); - - banner.stop(); -}); - -test.serial("Banner stops on ui5.log.stop-console", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - - const stopSpy = sinon.spy(banner, "stop"); - process.emit("ui5.log.stop-console"); - t.is(stopSpy.callCount, 1, "Banner.stop was triggered by ui5.log.stop-console"); - - sinon.restore(); -}); - -test.serial("Banner handles resize by re-rendering the status line", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - stdout.writes.length = 0; - - stdout.columns = 40; - stdout.emit("resize"); - - const after = stripAnsi(stdout.writes.join("")); - t.regex(after, /Status/, "resize triggers a status line re-render"); - - banner.stop(); -}); - -test.serial("Banner.stop is idempotent", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - banner.stop(); - t.notThrows(() => banner.stop(), "second stop() is a no-op"); -}); - -test.serial("Banner.observe: paints skeleton with placeholders, fills in via setters", (t) => { - const stdout = createStubStdout(); - const banner = Banner.observe({ - stdout, - brand: {name: "UI5 CLI", version: "1.0.0"}, - }); - - const skeleton = stripAnsi(stdout.writes.join("")); - // Brand is known up front; the rest is rendered as a placeholder so the - // layout is stable while we wait for the graph and the server. - t.regex(skeleton, /UI5 CLI v1\.0\.0/); - t.regex(skeleton, /Local:\s+binding…/); - t.regex(skeleton, /Project\s+resolving…/); - t.notRegex(skeleton, /my\.app/, "no project name before setProject"); - - // State updates still arrive during the resolve phase. - process.emit("ui5.serve-status", {status: "serve-building"}); - process.emit("ui5.build-metadata", {projectsToBuild: ["sap.ui.core"]}); - process.emit("ui5.build-status", { - projectName: "sap.ui.core", - projectType: "library", - status: "project-build-start", - }); - t.is(banner._getStateForTest().state, STATES.BUILDING, "state tracked from events"); - t.is(banner._getStateForTest().currentProjectName, "sap.ui.core"); - - // Filling in the header sections refines the skeleton in place. - banner.setProject({name: "my.app", type: "application", version: "1.0.0"}); - banner.setUrls({local: "http://localhost:8080"}); - - const filled = stripAnsi(stdout.writes.join("")); - t.regex(filled, /Local:\s+http:\/\/localhost:8080/); - t.regex(filled, /Project\s+my\.app/); - t.regex(filled, /building/, "status line reflects the building state"); - t.regex(filled, /sap\.ui\.core/); - - banner.stop(); -}); - -test.serial("Banner.observe: warn/error logs surface above the painted live region", (t) => { - const stdout = createStubStdout(); - const banner = Banner.observe({ - stdout, - brand: {name: "UI5 CLI", version: "1.0.0"}, - }); - - process.emit("ui5.log", { - level: "warn", - message: "early warning", - moduleName: "test:module", - }); - - const output = stripAnsi(stdout.writes.join("")); - t.regex(output, /early warning/, "warning is written above the live region"); - - banner.stop(); -}); - -test.serial("Banner.observe: stop() after paint flushes a trailing newline", (t) => { - const stdout = createStubStdout(); - const banner = Banner.observe({stdout}); - stdout.writes.length = 0; - banner.stop(); - // `log-update.done()` clears its internal state on stop; the banner - // itself emits a trailing newline so the next prompt lands on a fresh - // row below the final frame. Cursor visibility is restored by - // `cli-cursor` (writes to stderr, not this stdout stub). - const after = stdout.writes.join(""); - t.true(after.endsWith("\n"), "trailing newline written on stop"); -}); - -test.serial("Banner.observe: networkAddressCount reserves placeholder rows", (t) => { - const stdout = createStubStdout(); - const banner = Banner.observe({ - stdout, - brand: {name: "UI5 CLI", version: "1.0.0"}, - acceptRemoteConnections: true, - networkAddressCount: 2, - }); - // One Local placeholder plus `networkAddressCount` Network placeholders - // should appear so the live region doesn't reflow once real URLs arrive. - const skeleton = stripAnsi(stdout.writes.join("")); - const placeholderMatches = skeleton.match(/binding…/g) || []; - t.is(placeholderMatches.length, 3, - "one Local + two Network placeholders reserved"); - banner.stop(); -}); - -test.serial("Banner reacts to serve-ready event", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.serve-status", {status: "serve-building"}); - process.emit("ui5.serve-status", {status: "serve-ready"}); - t.is(banner._getStateForTest().state, STATES.READY); - banner.stop(); -}); - -test.serial("#transitionTo re-renders when the new state matches the current one", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - // Drive the banner into READY, then emit another serve-ready to hit the - // same-state branch in #transitionTo that re-renders without resetting - // the tick timer. log-update suppresses identical frame writes, so we - // just assert the banner stays in READY and the call doesn't throw. - process.emit("ui5.serve-status", {status: "serve-ready"}); - t.notThrows(() => process.emit("ui5.serve-status", {status: "serve-ready"})); - t.is(banner._getStateForTest().state, STATES.READY); - banner.stop(); -}); - -test.serial("Spinner tick advances spinFrame and re-renders while building", (t) => { - const clock = sinon.useFakeTimers(); - t.teardown(() => clock.restore()); - - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.serve-status", {status: "serve-building"}); - - const frameBefore = banner._getStateForTest().spinFrame; - stdout.writes.length = 0; - clock.tick(150); - const frameAfter = banner._getStateForTest().spinFrame; - - t.true(frameAfter > frameBefore, "spinFrame advanced on tick"); - t.true(stdout.writes.length > 0, "tick triggered a re-render"); - banner.stop(); -}); - -test.serial("logAbove writes directly to stdout after stop() with no live region", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - banner.stop(); - stdout.writes.length = 0; - // External callers may still hand the banner a line after stop() runs - // (e.g. a late warn from an in-flight task). Without a live region to - // erase, the banner just writes the line straight to stdout. - banner.logAbove("trailing warn"); - const after = stdout.writes.join(""); - t.regex(after, /trailing warn/, "line is written verbatim to stdout"); - t.true(after.endsWith("\n"), "newline appended"); -}); - -test.serial("Banner.observe with no arguments uses defaults", (t) => { - // Exercises the default-arg branches on observe()/constructor() and - // confirms the banner survives without a brand. stdout falls back to - // process.stdout — we route writes elsewhere to avoid polluting the - // test runner's output. - const realWrite = process.stdout.write.bind(process.stdout); - process.stdout.write = () => true; - t.teardown(() => { - process.stdout.write = realWrite; - }); - const banner = Banner.observe(); - t.is(banner._getStateForTest().brand, null); - banner.stop(); -}); - -test.serial("build-status for an unknown project increments the project counter", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.serve-status", {status: "serve-building"}); - process.emit("ui5.build-metadata", {projectsToBuild: ["known"]}); - // A project-build-start for a project that wasn't announced via - // build-metadata falls back to ++currentProjectIndex. - process.emit("ui5.build-status", { - projectName: "surprise", - status: "project-build-start", - }); - const state = banner._getStateForTest(); - t.is(state.currentProjectIndex, 1, "fallback path bumped the counter"); - t.is(state.currentProjectName, "surprise"); - banner.stop(); -}); - -test.serial("project-build-status does not shrink the task-name column", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.project-build-status", {taskName: "minifyAndBundle", status: "task-start"}); - process.emit("ui5.project-build-status", {taskName: "css", status: "task-start"}); - // The column width never shrinks: it stays at the longest name seen. - t.is(banner._getStateForTest().currentTaskName, "css"); - banner.stop(); -}); - -test.serial("serve-stale without a changedProjects payload falls back to an empty list", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.serve-status", {status: "serve-stale"}); - t.deepEqual(banner._getStateForTest().changedProjects, []); - banner.stop(); -}); - -test.serial("serve-error coerces a non-Error payload to a string", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.serve-status", {status: "serve-error", error: "plain string failure"}); - t.is(banner._getStateForTest().errorMessage, "plain string failure"); - banner.stop(); -}); - -test.serial("log events without moduleName render without a module prefix", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - stdout.writes.length = 0; - process.emit("ui5.log", {level: "warn", message: "module-less warning"}); - const output = stripAnsi(stdout.writes.join("")); - t.regex(output, /warn module-less warning/, - "warn line renders with just the level prefix and message"); - banner.stop(); -}); - -test.serial("build-status: project-build-skip also advances the counter", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.serve-status", {status: "serve-building"}); - process.emit("ui5.build-metadata", {projectsToBuild: ["a", "b"]}); - process.emit("ui5.build-status", {projectName: "b", status: "project-build-skip"}); - const state = banner._getStateForTest(); - t.is(state.currentProjectIndex, 2); - t.is(state.currentProjectName, "b"); - banner.stop(); -}); - -test.serial("build-status: unknown statuses are ignored", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - process.emit("ui5.serve-status", {status: "serve-building"}); - process.emit("ui5.build-metadata", {projectsToBuild: ["a", "b"]}); - const beforeIndex = banner._getStateForTest().currentProjectIndex; - // Statuses outside the known set must not advance the project counter - // — the banner's project tally is driven solely by build-start/skip. - process.emit("ui5.build-status", {projectName: "a", status: "project-build-end"}); - t.is(banner._getStateForTest().currentProjectIndex, beforeIndex, - "unknown status leaves the counter untouched"); - banner.stop(); -}); - -test.serial("project-build-status: non task-start events are ignored", (t) => { - const stdout = createStubStdout(); - const banner = createBanner(stdout); - // task-end (and any other non-start status) must not overwrite the - // currently-displayed task name — the live region keeps the most recent - // in-progress task visible until the next task-start. - process.emit("ui5.project-build-status", {taskName: "minify", status: "task-start"}); - process.emit("ui5.project-build-status", {taskName: "css", status: "task-end"}); - t.is(banner._getStateForTest().currentTaskName, "minify", - "task-end did not overwrite the active task name"); - banner.stop(); -}); - -test.serial("Banner survives a stdout without on/off methods", (t) => { - // A minimal write-only sink (no event emitter API) must not crash the - // banner's resize hook setup — the live banner is best-effort about - // terminal capabilities. - const stdout = { - columns: 80, - writes: [], - write(chunk) { - this.writes.push(chunk); - return true; - }, - }; - const banner = Banner.observe({stdout, brand: {name: "UI5 CLI", version: "1.0.0"}}); - t.notThrows(() => banner.stop(), "stop() handles stdout without off()"); -}); - -// ---- process.stdout / process.stderr interception --------------------------- -// The banner replaces the real process.stdout.write / process.stderr.write -// while active so writes that bypass @ui5/logger (custom tasks, third-party -// libs) get routed through logAbove() instead of corrupting the live region. -// These tests exercise that path against the real process streams — with the -// real writes stubbed out to keep the test runner's stdout quiet. - -function stubProcessStreams(t) { - const stdoutWrites = []; - const stderrWrites = []; - const trueOrigStdout = process.stdout.write; - const trueOrigStderr = process.stderr.write; - process.stdout.write = (chunk) => { - stdoutWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); - return true; - }; - process.stderr.write = (chunk) => { - stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); - return true; - }; - // `origStdout`/`origStderr` reference the STUBS — that's what the banner - // captured on install, and therefore what should be restored on stop. - const origStdout = process.stdout.write; - const origStderr = process.stderr.write; - t.teardown(() => { - process.stdout.write = trueOrigStdout; - process.stderr.write = trueOrigStderr; - }); - return {stdoutWrites, stderrWrites, origStdout, origStderr}; -} - -test.serial("Banner intercepts direct process.stderr.write and routes lines above the live region", (t) => { - const {stdoutWrites, origStderr} = stubProcessStreams(t); - const banner = Banner.observe({brand: {name: "UI5 CLI", version: "1.0.0"}}); - // The intercepted stream must NOT be the raw original — the wrapper the - // banner installed sits on top. - t.not(process.stderr.write, origStderr, "process.stderr.write is wrapped"); - - process.stderr.write("boom\n"); - - const output = stripAnsi(stdoutWrites.join("")); - t.regex(output, /boom/, "line written directly to stderr surfaces via logAbove"); - banner.stop(); - t.is(process.stderr.write, origStderr, "process.stderr.write is restored on stop"); -}); - -test.serial("Banner joins split-chunk writes into a single logged line", (t) => { - const {stdoutWrites} = stubProcessStreams(t); - const banner = Banner.observe({brand: {name: "UI5 CLI", version: "1.0.0"}}); - stdoutWrites.length = 0; - - // A single logical "foobar" line delivered as two chunks — a naive - // per-chunk logAbove call would emit two separate lines and desync the - // live region. The banner must buffer until the newline arrives. - process.stderr.write("foo"); - process.stderr.write("bar\n"); - - const output = stripAnsi(stdoutWrites.join("")); - t.regex(output, /foobar/, "split chunks are joined at the newline"); - banner.stop(); -}); - -test.serial("Banner.stop flushes a buffered partial line", (t) => { - const {stdoutWrites} = stubProcessStreams(t); - const banner = Banner.observe({brand: {name: "UI5 CLI", version: "1.0.0"}}); - stdoutWrites.length = 0; - - // No trailing newline — the fragment stays buffered until stop() flushes - // it. Without the flush the message would be lost entirely. - process.stderr.write("dangling fragment"); - banner.stop(); - - const output = stripAnsi(stdoutWrites.join("")); - t.regex(output, /dangling fragment/, "partial line flushed on stop"); -}); - -test.serial("Banner intercepts direct process.stdout.write too", (t) => { - const {stdoutWrites} = stubProcessStreams(t); - const banner = Banner.observe({brand: {name: "UI5 CLI", version: "1.0.0"}}); - stdoutWrites.length = 0; - - // A custom task that writes to stdout is just as bad for the live region - // as one writing to stderr — same TTY, same cursor. - process.stdout.write("stdout line\n"); - - const output = stripAnsi(stdoutWrites.join("")); - t.regex(output, /stdout line/, "line written directly to stdout surfaces via logAbove"); - banner.stop(); -}); - -test.serial("Banner interceptor invokes the write() callback asynchronously", async (t) => { - stubProcessStreams(t); - const banner = Banner.observe({brand: {name: "UI5 CLI", version: "1.0.0"}}); - - // stream.write(chunk, callback) contract: callback fires once the write - // drains. Our sink is synchronous, but the callback must still be async - // so the caller doesn't observe recursion inside its own write() call. - const observedDuringCall = await new Promise((resolve) => { - let calledSync = false; - process.stderr.write("cb line\n", () => { - resolve(calledSync); - }); - calledSync = true; - }); - t.true(observedDuringCall, "callback fires after the write() call returns"); - banner.stop(); -}); - -test.serial("Banner interceptor handles Buffer chunks with an explicit encoding", (t) => { - const {stdoutWrites} = stubProcessStreams(t); - const banner = Banner.observe({brand: {name: "UI5 CLI", version: "1.0.0"}}); - stdoutWrites.length = 0; - - process.stderr.write(Buffer.from("buffered line\n", "utf8"), "utf8"); - - const output = stripAnsi(stdoutWrites.join("")); - t.regex(output, /buffered line/, "buffer chunk decoded and surfaced"); - banner.stop(); -}); - -test.serial("Banner interception can be disabled via opts.interceptProcessWrites=false", (t) => { - const {origStderr} = stubProcessStreams(t); - const banner = Banner.observe({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - interceptProcessWrites: false, - }); - // With interception off, the wrapper the banner would have installed is - // absent — process.stderr.write is still the stub set by the test. - t.is(process.stderr.write, origStderr, - "stderr.write is untouched when interception is disabled"); - banner.stop(); -}); - -test.serial("Banner does not intercept when a stub stdout is supplied", (t) => { - const {origStderr} = stubProcessStreams(t); - const stdout = createStubStdout(); - const banner = Banner.observe({stdout, brand: {name: "UI5 CLI", version: "1.0.0"}}); - // Interception is gated on stdout === process.stdout — the vast majority - // of unit tests pass a stub stdout and must not have process.stderr - // yanked out from under them. - t.is(process.stderr.write, origStderr, - "stderr.write is untouched when a stub stdout is used"); - banner.stop(); -}); diff --git a/packages/cli/test/lib/serve/render.js b/packages/cli/test/lib/serve/render.js deleted file mode 100644 index 9edd57f8df6..00000000000 --- a/packages/cli/test/lib/serve/render.js +++ /dev/null @@ -1,327 +0,0 @@ -import test from "ava"; -import stripAnsi from "strip-ansi"; -import chalk from "chalk"; -import figures from "figures"; - -import { - renderHeader, - renderStatusLine, -} from "../../../lib/serve/render.js"; -import {REMOTE_CONNECTIONS_WARNING_LINES} from "../../../lib/serve/remoteConnectionsWarning.js"; -import {createInitialState, STATES} from "../../../lib/serve/state.js"; - -// chalk auto-detects "no color" in non-TTY subprocesses (e.g. the AVA worker), which would strip -// every color code from renderStatusLine and defeat the color-per-state assertions below. Force -// truecolor for this file. All other tests here run through stripAnsi and tolerate either mode. -chalk.level = 3; - -const baseHeaderOpts = { - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: {local: "http://localhost:8080"}, - acceptRemoteConnections: false, - project: {name: "my.app", type: "application", version: "1.0.0"}, - framework: {name: "SAPUI5", version: "1.0.0"}, -}; - -test("renderHeader: includes brand, urls, project, framework", (t) => { - const lines = renderHeader(baseHeaderOpts); - const plain = lines.map(stripAnsi).join("\n"); - t.regex(plain, /UI5 CLI v1\.0\.0/); - t.regex(plain, /Local:\s+http:\/\/localhost:8080/); - t.regex(plain, /Network:\s+use --accept-remote-connections/); - t.regex(plain, /Project\s+my\.app\s+\(application\)\s+v1\.0\.0/); - t.regex(plain, /Framework\s+SAPUI5 1\.0\.0/); -}); - -test("renderHeader: omits warning block when acceptRemoteConnections=false", (t) => { - const lines = renderHeader(baseHeaderOpts); - const plain = lines.map(stripAnsi).join("\n"); - t.false(plain.includes("accepting connections from all hosts"), - "warning sub-block is omitted when remote connections are not accepted"); -}); - -test("renderHeader: emits the full warning block when acceptRemoteConnections=true", (t) => { - const lines = renderHeader({...baseHeaderOpts, acceptRemoteConnections: true}); - const plain = lines.map(stripAnsi).join("\n"); - for (const line of REMOTE_CONNECTIONS_WARNING_LINES) { - t.true(plain.includes(stripAnsi(line)), - `expected warning line to be present: ${stripAnsi(line)}`); - } -}); - -test("renderHeader: shows a dim '(none)' framework row when project has no framework", (t) => { - // The Framework row is always rendered so the live region's height stays - // constant once the project resolves — otherwise the status line would - // shift down when a framework appears between frames. - const lines = renderHeader({...baseHeaderOpts, framework: undefined}); - const plain = lines.map(stripAnsi).join("\n"); - t.regex(plain, /Framework\s+\(none\)/); -}); - -test("renderHeader: renders placeholders when urls + project are unknown", (t) => { - const lines = renderHeader({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: null, - project: null, - framework: null, - acceptRemoteConnections: false, - }); - const plain = lines.map(stripAnsi).join("\n"); - t.regex(plain, /UI5 CLI v1\.0\.0/); - t.regex(plain, /Local:\s+binding…/); - t.regex(plain, /Network:\s+use --accept-remote-connections to expose/); - t.regex(plain, /Project\s+resolving…/); - t.regex(plain, /Framework\s+resolving…/, - "framework row reserved with a placeholder while the project is unknown"); -}); - -test("renderHeader: shows remote-connections warning as soon as the flag is set", (t) => { - // `acceptRemoteConnections` reflects user intent — the renderer paints the - // warning from the first frame, well before the server has actually bound. - const lines = renderHeader({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: null, - project: null, - framework: null, - acceptRemoteConnections: true, - }); - const plain = lines.map(stripAnsi).join("\n"); - t.true(plain.includes("accepting connections from all hosts"), - "warning surfaces with the placeholder header"); -}); - -test("renderHeader: network placeholder reserves one line per expected address", (t) => { - // The CLI hands `networkAddressCount` to Banner.observe so the skeleton - // pre-reserves the exact number of rows that setUrls() will later fill — - // the live region must not re-flow when the real URLs arrive. - const skeleton = renderHeader({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: null, - project: null, - framework: null, - acceptRemoteConnections: true, - networkAddressCount: 3, - }); - const skeletonNetworkLines = skeleton.map(stripAnsi) - .filter((l) => /^\s*(.+?\s+)?Network:|^\s+binding…$/.test(l) || - /^\s+binding…$/.test(l)); - // One labelled row + two continuation rows under the same indent. - t.is(skeletonNetworkLines.length, 3, "placeholder reserves 3 network rows"); - - const filled = renderHeader({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: { - local: "http://localhost:8080", - network: [ - "http://0.0.0.0:8080", - "http://0.0.0.0:8081", - "http://0.0.0.0:8082", - ], - }, - project: null, - framework: null, - acceptRemoteConnections: true, - networkAddressCount: 3, - }); - - // The total number of header lines must be identical before and after - // setUrls() — that's what keeps the live region from re-flowing. - t.is(filled.length, skeleton.length, - "setUrls() preserves the header's line count"); -}); - -test("renderHeader: network placeholder defaults to a single line when count is zero", (t) => { - // `acceptRemoteConnections=true` but no addresses found yet (count=0): the - // section still needs a visible placeholder so the user sees the row even - // before the host's interfaces are inspected. - const lines = renderHeader({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: null, - project: null, - framework: null, - acceptRemoteConnections: true, - networkAddressCount: 0, - }); - const plain = lines.map(stripAnsi).join("\n"); - const matches = plain.match(/Network:\s+binding…/g) || []; - t.is(matches.length, 1, "exactly one placeholder Network: row is rendered"); -}); - -test("renderHeader: row count is stable across project resolution (with framework)", (t) => { - // The live region must not change height once the project resolves — - // otherwise log-update's diff path leaves trailing rows (notably the - // status line) where they were and the user sees a duplicate. - const skeleton = renderHeader({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: null, - project: null, - framework: null, - acceptRemoteConnections: false, - }); - const withFramework = renderHeader({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: {local: "http://localhost:8080"}, - project: {name: "my.app", type: "application", version: "1.0.0"}, - framework: {name: "SAPUI5", version: "1.0.0"}, - acceptRemoteConnections: false, - }); - const withoutFramework = renderHeader({ - brand: {name: "UI5 CLI", version: "1.0.0"}, - urls: {local: "http://localhost:8080"}, - project: {name: "my.app", type: "application", version: "1.0.0"}, - framework: null, - acceptRemoteConnections: false, - }); - t.is(withFramework.length, skeleton.length, - "frame with a framework matches the skeleton's height"); - t.is(withoutFramework.length, skeleton.length, - "frame without a framework matches the skeleton's height"); -}); - -test("renderStatusLine: ready state", (t) => { - const state = {...createInitialState(), state: STATES.READY}; - const plain = stripAnsi(renderStatusLine(state)); - t.regex(plain, /Status\s+.+?\s+ready/); -}); - -test("renderStatusLine: stale state", (t) => { - const state = {...createInitialState(), state: STATES.STALE}; - const plain = stripAnsi(renderStatusLine(state)); - t.regex(plain, /Status\s+.+?\s+stale\s+·\s+files changed/); -}); - -test("renderStatusLine: building state with project + task", (t) => { - const state = { - ...createInitialState(), - state: STATES.BUILDING, - totalProjects: 3, - currentProjectIndex: 2, - currentProjectName: "my.app", - currentTaskName: "minify", - }; - const plain = stripAnsi(renderStatusLine(state)); - t.regex(plain, /building/); - t.regex(plain, /2\/3 projects/); - t.regex(plain, /my\.app/); - t.regex(plain, /minify/); -}); - -test("renderStatusLine: error state shows message", (t) => { - const state = { - ...createInitialState(), - state: STATES.ERROR, - errorMessage: "Build failed", - }; - const plain = stripAnsi(renderStatusLine(state)); - t.regex(plain, /.+?\s+error\s+·\s+Build failed/); -}); - -test("renderStatusLine: building spinner cycles through frames", (t) => { - const seen = new Set(); - for (let frame = 0; frame < 8; frame++) { - const state = { - ...createInitialState(), - state: STATES.BUILDING, - spinFrame: frame, - }; - // extract the spinner glyph: first non-space, non-bracket char after the - // label whitespace - const plain = stripAnsi(renderStatusLine(state)); - const match = plain.match(/Status\s+(\S)/); - t.truthy(match, "expected to find a spinner glyph"); - seen.add(match[1]); - } - t.true(seen.size > 1, "spinner glyph rotates across frames"); -}); - -test("renderStatusLine: unknown state falls back to a bare label", (t) => { - // Defensive default case in the state switch — guards against future - // callers passing a state value the renderer doesn't recognise yet. - const plain = stripAnsi(renderStatusLine({state: "totally-new-state"})); - t.regex(plain, /Status/); -}); - -test("renderStatusLine: error state without message omits the dot separator", (t) => { - const state = {...createInitialState(), state: STATES.ERROR, errorMessage: ""}; - const plain = stripAnsi(renderStatusLine(state)); - t.regex(plain, /.+?\s+error\s*$/, "error state renders without a message tail"); -}); - -test("renderHeader: lists every network URL when several addresses are supplied", (t) => { - const lines = renderHeader({ - ...baseHeaderOpts, - acceptRemoteConnections: true, - urls: { - local: "http://localhost:8080", - network: [ - "http://10.0.0.1:8080", - "http://10.0.0.2:8080", - "http://10.0.0.3:8080", - ], - }, - }); - const plain = lines.map(stripAnsi).join("\n"); - t.regex(plain, /Network:\s+http:\/\/10\.0\.0\.1:8080/); - t.regex(plain, /http:\/\/10\.0\.0\.2:8080/); - t.regex(plain, /http:\/\/10\.0\.0\.3:8080/); -}); - -test("renderHeader: project rendered without type/version still includes the name", (t) => { - const lines = renderHeader({ - ...baseHeaderOpts, - project: {name: "bare.project"}, - framework: null, - }); - const plain = lines.map(stripAnsi).join("\n"); - t.regex(plain, /Project\s+bare\.project/); - t.notRegex(plain, /bare\.project\s+\(/, "no type marker is rendered when type is absent"); -}); - -test("renderHeader: framework name without a version still renders", (t) => { - const lines = renderHeader({ - ...baseHeaderOpts, - framework: {name: "OpenUI5"}, - }); - const plain = lines.map(stripAnsi).join("\n"); - t.regex(plain, /Framework\s+OpenUI5/); -}); - -// Locks the glyph chosen per state. Complements the shape-only regex tests above: those verify -// "some glyph is followed by the state word"; these verify "it's specifically THIS glyph". A swap -// (e.g. bullet ↔ circle) would still look plausible but would break the design contract. -const GLYPH_BY_STATE = [ - {state: STATES.INITIAL, glyph: figures.circle, name: "circle"}, - {state: STATES.READY, glyph: figures.bullet, name: "bullet"}, - {state: STATES.STALE, glyph: figures.circle, name: "circle"}, - {state: STATES.ERROR, glyph: figures.cross, name: "cross"}, -]; -for (const {state, glyph, name} of GLYPH_BY_STATE) { - test(`renderStatusLine: ${state} uses the ${name} glyph`, (t) => { - const plain = stripAnsi(renderStatusLine({...createInitialState(), state})); - t.true(plain.includes(glyph), - `expected output to contain ${JSON.stringify(glyph)}, got ${JSON.stringify(plain)}`); - }); -} - -// Locks the color chosen per state. Color is the primary semantic signal to a human reader -// (green=healthy, yellow=attention, red=error) and stripAnsi throws it away — so the plain-text -// tests above would happily accept a swap. Build the expected wrapped-word substring with the -// same chalk instance the renderer uses; matching it in the output proves the state word is -// wrapped in exactly that color span (not merely that the code appears somewhere on the line). -const WIDTH = "building".length; -const COLOR_BY_STATE = [ - {state: STATES.INITIAL, wrap: (x) => chalk.dim(x), word: "starting", name: "dim"}, - {state: STATES.READY, wrap: (x) => chalk.green(x), word: "ready", name: "green"}, - {state: STATES.STALE, wrap: (x) => chalk.yellow(x), word: "stale", name: "yellow"}, - {state: STATES.BUILDING, wrap: (x) => chalk.yellow(x), word: "building", name: "yellow"}, - {state: STATES.ERROR, wrap: (x) => chalk.red(x), word: "error", name: "red"}, -]; -for (const {state, wrap, word, name} of COLOR_BY_STATE) { - test(`renderStatusLine: ${state} state is rendered in ${name}`, (t) => { - const out = renderStatusLine({...createInitialState(), state}); - const expected = wrap(word.padEnd(WIDTH)); - t.true(out.includes(expected), - `expected the "${word}" label to be wrapped in ${name}; ` + - `looked for ${JSON.stringify(expected)} in ${JSON.stringify(out)}`); - }); -} diff --git a/packages/logger/lib/writers/Console.js b/packages/logger/lib/writers/Console.js index 846701cf807..2776f494796 100644 --- a/packages/logger/lib/writers/Console.js +++ b/packages/logger/lib/writers/Console.js @@ -3,6 +3,7 @@ import {chalkStderr as chalk} from "chalk"; import figures from "figures"; import {MultiBar} from "cli-progress"; import Logger from "../loggers/Logger.js"; +import {getLevelPrefix} from "./internal/levelPrefix.js"; /** * Standard handler for events emitted by @ui5/logger modules. Writes messages to @@ -29,6 +30,8 @@ class Console { this._handleProjectBuildStatusEvent = this.#handleProjectBuildStatusEvent.bind(this); this._handleBuildMetadataEvent = this.#handleBuildMetadataEvent.bind(this); this._handleProjectBuildMetadataEvent = this.#handleProjectBuildMetadataEvent.bind(this); + this._handleProjectResolvedEvent = this.#handleProjectResolvedEvent.bind(this); + this._handleServerListeningEvent = this.#handleServerListeningEvent.bind(this); this._handleStop = this.disable.bind(this); this.#initFiters(); } @@ -109,6 +112,8 @@ class Console { process.on("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent); process.on("ui5.build-status", this._handleBuildStatusEvent); process.on("ui5.project-build-status", this._handleProjectBuildStatusEvent); + process.on("ui5.project-resolved", this._handleProjectResolvedEvent); + process.on("ui5.server-listening", this._handleServerListeningEvent); process.on("ui5.log.stop-console", this._handleStop); } @@ -123,6 +128,8 @@ class Console { process.off("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent); process.off("ui5.build-status", this._handleBuildStatusEvent); process.off("ui5.project-build-status", this._handleProjectBuildStatusEvent); + process.off("ui5.project-resolved", this._handleProjectResolvedEvent); + process.off("ui5.server-listening", this._handleServerListeningEvent); process.off("ui5.log.stop-console", this._handleStop); if (this.#progressBarContainer) { this.#progressBar.stop(); @@ -191,7 +198,7 @@ class Console { if (!Logger.isLevelEnabled(level)) { return; } - const levelPrefix = this.#getLevelPrefix(level); + const levelPrefix = getLevelPrefix(level); const msg = `${levelPrefix} ${message}\n`; if (this.#progressBarContainer) { @@ -427,32 +434,41 @@ class Console { this.#writeMessage(level, `${chalk.blue(`${(projectName)}`)} ${taskIndex}${message}`); } - #getLevelPrefix(level) { - switch (level) { - case "silly": - return chalk.inverse(level); - case "verbose": - return chalk.cyan("verb"); - case "perf": - return chalk.bgYellow.red(level); - case "info": - return chalk.green(level); - case "warn": - return chalk.yellow(level); - case "error": - return chalk.bgRed.white(level); - default: - // Log level silent does not produce messages - throw new Error(`writers/Console: Invalid message log level "${level}"`); + #handleProjectResolvedEvent({name, type, version}) { + // One-line summary of the root project, mirroring the header region of + // the interactive writer. Kept as an info line so `ui5 build` and + // non-TTY `ui5 serve` still record the project identity in scrollback. + const versionSuffix = version ? ` (${version})` : ""; + const typeLabel = type ? `${type} project ` : ""; + this.#writeMessage("info", `Root project: ${typeLabel}${chalk.bold(name)}${chalk.dim(versionSuffix)}`); + } + + #handleServerListeningEvent({urls, acceptRemoteConnections}) { + if (acceptRemoteConnections) { + // The interactive writer renders the remote-connections warning as a + // dedicated block. The plain writer emits it as a warning line so it + // still surfaces in non-TTY logs and pipes. + this.#writeMessage("warn", + `Server is accepting remote connections from all hosts on your network. ` + + `This is intended for development purposes only.`); + } + if (Array.isArray(urls)) { + for (const entry of urls) { + const label = entry?.label ? `${entry.label}: ` : ""; + this.#writeMessage("info", `Server listening on ${label}${entry?.url ?? ""}`); + } } } /** - * Creates a new instance and subscribes it to all events + * Creates a new instance and subscribes it to all events. Any currently- + * active console-writing writer is displaced by the `ui5.log.stop-console` + * event emitted here. * * @public */ static init() { + process.emit("ui5.log.stop-console"); const cH = new Console(); cH.enable(); return cH; diff --git a/packages/cli/lib/serve/Banner.js b/packages/logger/lib/writers/InteractiveConsole.js similarity index 50% rename from packages/cli/lib/serve/Banner.js rename to packages/logger/lib/writers/InteractiveConsole.js index 1ae08b05ec5..34e25738fa3 100644 --- a/packages/cli/lib/serve/Banner.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -2,21 +2,17 @@ import process from "node:process"; import {createLogUpdate} from "log-update"; import sliceAnsi from "slice-ansi"; import chalk from "chalk"; -import Logger from "@ui5/logger/Logger"; -import {createInitialState, STATES} from "./state.js"; -import {renderHeader, renderStatusLine} from "./render.js"; - -// TODO: Consider moving the banner and related modules into @ui5/logger/writers/, similar to writers/Console, -// which covers all console output for the server. -// The serve command could then also be simplified by letting the server itself emit @ui5/logger/loggers/Serve -// events to provide the banner data. - -// Mirror the level prefixes used by @ui5/logger's default ConsoleWriter so -// scrolled-back log lines look identical whether or not the banner is active. -const LEVEL_PREFIX = { - warn: chalk.yellow("warn"), - error: chalk.bgRed.white("error"), -}; +import Logger from "../loggers/Logger.js"; +import {getLevelPrefix} from "./internal/levelPrefix.js"; +import {createHeaderState, setTool} from "./interactiveConsole/state/header.js"; +import {createProjectState, setProject} from "./interactiveConsole/state/project.js"; +import {createServerState, setListening} from "./interactiveConsole/state/server.js"; +import { + createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, STATES, +} from "./interactiveConsole/state/build.js"; +import { + renderHeaderRegion, renderProjectRegion, renderServerRegion, renderBuildRegion, +} from "./interactiveConsole/render.js"; // Spinner tick interval while in `building` state. const BUILDING_TICK_MS = 120; @@ -34,36 +30,35 @@ function parseWriteArgs(encodingOrCallback, maybeCallback) { } /** - * Live banner for `ui5 serve`. Owns process.stdout while active. + * Interactive console writer for `ui5 serve` — a live, region-based status + * display rendered in a persistent bottom region. Owns process.stderr while + * active. Sibling of {@link module:@ui5/logger/writers/Console|writers/Console}; + * exactly one console-writing writer may be active at a time. + *

+ * The writer is universal: it renders whichever regions have received data. + * 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. * - * Subscribes to the @ui5/logger event surface (ui5.log, ui5.build-metadata, - * ui5.build-status, ui5.project-build-status, ui5.serve-status, ui5.log.stop-console) - * and renders a header + status line as a single live region. As more data - * becomes known (project resolved, server bound), the header is repainted - * in place — sections that aren't known yet show dim placeholders. - * - * The actual terminal bookkeeping (wrap-aware erase of the previous frame, - * cursor hide/show, screen-row vs. logical-line accounting) is delegated to - * the `log-update` package, which counts screen rows after wrap rather than - * logical lines — important when warnings exceed the terminal width. - * - * Lifecycle: {@link Banner.observe} paints the skeleton immediately (brand - * only, placeholders for the rest) and attaches the event listeners. Use it - * as soon as `ui5 serve` decides it wants a banner — well before the project - * graph is built — so the user sees feedback right away. Section setters - * ({@link Banner#setProject}, {@link Banner#setUrls}) then update individual - * header sections and repaint in place. + * @public + * @class + * @alias @ui5/logger/writers/InteractiveConsole */ -class Banner { - #stdout; +class InteractiveConsole { + #stderr; #stopped = false; - #state; - #layout = {projectNameWidth: 0, taskNameWidth: 0}; + + #headerState; + #projectState; + #serverState; + #buildState; + #tickTimer = null; #columns; - // Map of build-status: track which project index is next. - #projectOrder = []; - // `log-update` instance bound to `#stdout`. Owns the live region: every + // `log-update` instance bound to `#stderr`. Owns the live region: every // `#render()` call hands it the full multi-line frame and it diffs against // the previous one, erasing wrap-correctly. #logUpdate; @@ -81,121 +76,109 @@ class Banner { stderr: {orig: null, partial: ""}, }; + #seenProjectResolved = false; + // Bound listeners so we can `process.off` them on stop(). #onLog; #onBuildMetadata; #onBuildStatus; #onProjectBuildStatus; #onServeStatus; + #onToolInfo; + #onProjectResolved; + #onServerListening; #onStopConsole; #onResize; + constructor({stderr = process.stderr} = {}) { + this.#stderr = stderr; + this.#headerState = createHeaderState(); + this.#projectState = createProjectState(); + this.#serverState = createServerState(); + this.#buildState = createBuildState(); + this.#columns = stderr.columns; + } + /** - * Paint the banner skeleton immediately (with placeholders for any - * unknown sections) and attach the event listeners. Subsequent section - * setters refine the skeleton in place. + * Attaches all event listeners and starts writing to the output stream. + * Emits `ui5.log.stop-console` first so any currently-active console- + * writing writer detaches before this one takes over. * - * @param {object} [opts] - * @param {WriteStream} [opts.stdout] - * @param {object} [opts.brand] { name, version } — usually known up front - * @param {boolean} [opts.acceptRemoteConnections] known up front from argv - * @param {number} [opts.networkAddressCount] how many network URLs setUrls() - * will later supply — used so the initial placeholder reserves the - * correct number of lines and the live region doesn't re-flow. - * @param {boolean} [opts.interceptProcessWrites=true] wrap - * `process.stdout.write` / `process.stderr.write` so raw writes from - * custom tasks or third-party libraries are re-routed through - * {@link Banner#logAbove} instead of corrupting the live region. - * Tests that supply a stub `stdout` opt out of this by default — - * interception activates only when `opts.stdout` is unset or is the - * real `process.stdout`. - * @returns {Banner} + * @public */ - static observe(opts = {}) { - const banner = new Banner(opts); - if (opts.brand) { - banner.#state.brand = opts.brand; - } - if ("acceptRemoteConnections" in opts) { - banner.#state.acceptRemoteConnections = !!opts.acceptRemoteConnections; - } - if (typeof opts.networkAddressCount === "number") { - banner.#state.networkAddressCount = opts.networkAddressCount; + enable() { + process.emit("ui5.log.stop-console"); + this.#stopped = false; + this.#attachListeners(); + if (!this.#logUpdate) { + this.#logUpdate = createLogUpdate(this.#stderr); } - banner.#attachListeners(); - banner.#logUpdate = createLogUpdate(banner.#stdout); - banner.#render(); - banner.#scheduleTick(); - const wantsIntercept = opts.interceptProcessWrites !== false && - banner.#stdout === process.stdout; + const wantsIntercept = this.#stderr === process.stderr; if (wantsIntercept) { - banner.#installWriteInterceptors(); + this.#installWriteInterceptors(); } - return banner; - } - - constructor({stdout = process.stdout} = {}) { - this.#stdout = stdout; - this.#state = createInitialState(); - this.#columns = stdout.columns; } /** - * Update the project + framework section and repaint. Pass `framework` - * as `null` (or omit) for projects without a framework dependency. + * Detaches all event listeners and stops writing to the output stream. * - * @param {object} info { name, type, version, framework? } + * @public */ - setProject(info) { - this.#state.project = { - name: info.name, - type: info.type, - version: info.version, - }; - this.#state.framework = info.framework || null; - this.#render(); - } - - /** - * Update the URLs section (local + optional network) and repaint. - * - * @param {object} info - * @param {string} info.local - * @param {string[]} [info.network] - */ - setUrls(info) { - this.#state.urls = { - local: info.local, - network: info.network, - }; - this.#render(); + disable() { + if (this.#stopped) { + return; + } + this.#stopped = true; + this.#clearTick(); + this.#detachListeners(); + // Flush any partial (non-newline-terminated) buffered writes before + // we tear down the live region, then restore the process.* originals. + // Flushing first while the live region is still on-screen keeps the + // trailing fragment above the final frame instead of after it. + this.#flushPartialBuffers(); + this.#uninstallWriteInterceptors(); + // `done` restores the cursor (which log-update hid on first render) + // and resets state. End on a clean newline so the prompt lands + // below the final frame. + this.#withRenderingGuard(() => this.#logUpdate?.done()); + this.#stderr.write("\n"); } - // Compose the full live region as a single string: header lines + blank - // separator + status line. `log-update` handles wrapping and erase of the - // previous frame, so we just hand it the full text. + // Compose the full live region as a single string: one block per region, + // separated by their own leading blank line. `log-update` handles wrapping + // and erase of the previous frame, so we just hand it the full text. #composeLiveRegion() { - const headerLines = renderHeader({ - brand: this.#state.brand, - urls: this.#state.urls, - acceptRemoteConnections: this.#state.acceptRemoteConnections, - networkAddressCount: this.#state.networkAddressCount, - project: this.#state.project, - framework: this.#state.framework, - }); - // `log-update` wraps long lines onto additional rows; the status line is - // designed to fit on a single row, so clip it to the terminal width here. - // `this.#columns` is undefined when stdout isn't a TTY — sliceAnsi treats - // that as "no limit" and returns the line unchanged. - const statusLine = sliceAnsi( - renderStatusLine(this.#state, this.#layout), 0, this.#columns); - return [...headerLines, "", statusLine].join("\n"); + const lines = []; + for (const region of [ + renderHeaderRegion(this.#headerState), + renderProjectRegion(this.#projectState), + renderServerRegion(this.#serverState), + renderBuildRegion(this.#buildState), + ]) { + for (const line of region) { + lines.push(line); + } + } + if (lines.length === 0) { + return ""; + } + // `log-update` wraps long lines onto additional rows; the last line is + // designed to fit on a single row when it's a status line, so clip it + // to the terminal width. `this.#columns` is undefined when stderr isn't + // a TTY — sliceAnsi treats that as "no limit" and returns unchanged. + if (this.#buildState.state !== STATES.INITIAL) { + lines[lines.length - 1] = sliceAnsi(lines[lines.length - 1], 0, this.#columns); + } + return lines.join("\n"); } #render() { if (this.#stopped) { return; } + if (!this.#logUpdate) { + return; + } this.#withRenderingGuard(() => this.#logUpdate(this.#composeLiveRegion())); } @@ -205,45 +188,21 @@ class Banner { * Delegates to `log-update`'s `persist()` (which erases the live region, * writes the line in its place, and accounts for wrapped lines correctly) * followed by a fresh render of the live region below it. Pass a string - * with embedded newlines to persist multiple lines in a single frame — - * cheaper than N separate `logAbove` calls when bursts arrive. + * with embedded newlines to persist multiple lines in a single frame. * * @param {string} line The log line to write above the live region. */ logAbove(line) { if (this.#stopped) { - this.#stdout.write(line + "\n"); + this.#stderr.write(line + "\n"); return; } - // `persist` writes `line` where the live region currently sits and - // resets log-update's internal frame state. The follow-up render - // re-creates the live region right below it. this.#withRenderingGuard(() => { this.#logUpdate.persist(line); this.#logUpdate(this.#composeLiveRegion()); }); } - stop() { - if (this.#stopped) { - return; - } - this.#stopped = true; - this.#clearTick(); - this.#detachListeners(); - // Flush any partial (non-newline-terminated) buffered writes before - // we tear down the live region, then restore the process.* originals. - // Flushing first while the live region is still on-screen keeps the - // trailing fragment above the final frame instead of after it. - this.#flushPartialBuffers(); - this.#uninstallWriteInterceptors(); - // `done` restores the cursor (which log-update hid on first render) - // and resets state. End on a clean newline so the prompt lands - // below the final frame. - this.#withRenderingGuard(() => this.#logUpdate?.done()); - this.#stdout.write("\n"); - } - // Run `fn` with the re-render flag set so log-update's own writes to // the intercepted process streams pass through untouched. Any nested // stdout/stderr write inside `fn` will bypass the line-buffering path. @@ -264,7 +223,10 @@ class Banner { this.#onBuildStatus = (evt) => this.#handleBuildStatus(evt); this.#onProjectBuildStatus = (evt) => this.#handleProjectBuildStatus(evt); this.#onServeStatus = (evt) => this.#handleServeStatus(evt); - this.#onStopConsole = () => this.stop(); + this.#onToolInfo = (evt) => this.#handleToolInfo(evt); + this.#onProjectResolved = (evt) => this.#handleProjectResolved(evt); + this.#onServerListening = (evt) => this.#handleServerListening(evt); + this.#onStopConsole = () => this.disable(); this.#onResize = () => this.#handleResize(); process.on("ui5.log", this.#onLog); @@ -272,9 +234,12 @@ class Banner { process.on("ui5.build-status", this.#onBuildStatus); process.on("ui5.project-build-status", this.#onProjectBuildStatus); process.on("ui5.serve-status", this.#onServeStatus); + process.on("ui5.tool-info", this.#onToolInfo); + process.on("ui5.project-resolved", this.#onProjectResolved); + process.on("ui5.server-listening", this.#onServerListening); process.on("ui5.log.stop-console", this.#onStopConsole); - if (typeof this.#stdout.on === "function") { - this.#stdout.on("resize", this.#onResize); + if (typeof this.#stderr.on === "function") { + this.#stderr.on("resize", this.#onResize); } } @@ -284,63 +249,74 @@ class Banner { process.off("ui5.build-status", this.#onBuildStatus); process.off("ui5.project-build-status", this.#onProjectBuildStatus); process.off("ui5.serve-status", this.#onServeStatus); + process.off("ui5.tool-info", this.#onToolInfo); + process.off("ui5.project-resolved", this.#onProjectResolved); + process.off("ui5.server-listening", this.#onServerListening); process.off("ui5.log.stop-console", this.#onStopConsole); - if (typeof this.#stdout.off === "function") { - this.#stdout.off("resize", this.#onResize); + if (typeof this.#stderr.off === "function") { + this.#stderr.off("resize", this.#onResize); } } #handleLog({level, message, moduleName}) { // The banner curates `info` away (the status line already represents // the build's state). Verbose / perf / silly users hit the fallback - // path before the banner ever activates. Warnings and errors persist — + // path before the writer ever activates. Warnings and errors persist — // but only if the configured log level lets them through. The standard // ConsoleWriter does this filtering implicitly via its `#writeMessage`; - // the banner acts as its own writer here, so it has to check too. + // this writer acts as its own sink here, so it has to check too. if (level !== "warn" && level !== "error") { return; } if (!Logger.isLevelEnabled(level)) { return; } - const levelPrefix = LEVEL_PREFIX[level]; + const levelPrefix = getLevelPrefix(level); const formatted = moduleName ? `${levelPrefix} ${chalk.blue(moduleName)} ${message}` : `${levelPrefix} ${message}`; this.logAbove(formatted); } + #handleToolInfo(evt) { + setTool(this.#headerState, evt); + this.#render(); + } + + #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. + throw new Error( + `writers/InteractiveConsole: Received duplicate ui5.project-resolved event`); + } + this.#seenProjectResolved = true; + setProject(this.#projectState, evt); + this.#render(); + } + + #handleServerListening(evt) { + setListening(this.#serverState, evt); + this.#render(); + } + #handleBuildMetadata({projectsToBuild}) { - this.#projectOrder = Array.from(projectsToBuild); - this.#state.totalProjects = this.#projectOrder.length; - this.#state.currentProjectIndex = 0; - this.#state.currentProjectName = ""; - this.#state.currentTaskName = ""; - this.#layout.projectNameWidth = this.#projectOrder.reduce( - (max, name) => Math.max(max, name.length), 0); + beginBuild(this.#buildState, projectsToBuild); this.#render(); } #handleBuildStatus({projectName, status}) { if (status === "project-build-start" || status === "project-build-skip") { - const idx = this.#projectOrder.indexOf(projectName); - this.#state.currentProjectIndex = idx >= 0 ? - idx + 1 : - this.#state.currentProjectIndex + 1; - this.#state.currentProjectName = projectName; - this.#state.currentTaskName = ""; + advanceToProject(this.#buildState, projectName); this.#render(); } } #handleProjectBuildStatus({taskName, status}) { if (status === "task-start") { - this.#state.currentTaskName = taskName; - // Task names aren't known up front; widen the column the first time - // we see a longer one so subsequent renders don't reflow. - if (taskName.length > this.#layout.taskNameWidth) { - this.#layout.taskNameWidth = taskName.length; - } + setTask(this.#buildState, taskName); this.#render(); } } @@ -351,23 +327,21 @@ class Banner { this.#transitionTo(STATES.READY); break; case "serve-stale": - this.#state.changedProjects = evt.changedProjects || []; + this.#buildState.changedProjects = evt.changedProjects || []; this.#transitionTo(STATES.STALE); break; case "serve-building": - this.#state.currentProjectIndex = 0; - this.#state.currentProjectName = ""; - this.#state.currentTaskName = ""; - this.#state.spinFrame = 0; + beginBuild(this.#buildState, this.#buildState.projectOrder); this.#transitionTo(STATES.BUILDING); break; case "serve-build-done": - this.#state.lastBuildHrtime = Array.isArray(evt.hrtime) ? evt.hrtime : null; + this.#buildState.lastBuildHrtime = Array.isArray(evt.hrtime) ? evt.hrtime : null; this.#transitionTo(STATES.READY); break; case "serve-error": - this.#state.errorMessage = evt.error?.message || String(evt.error); - this.#transitionTo(STATES.ERROR); + setError(this.#buildState, evt.error?.message || String(evt.error)); + this.#clearTick(); + this.#render(); // Don't echo the error via logAbove — BuildServer already logs // the same message at `error` level (which #handleLog scrolls // above the banner), and the yargs fail-handler renders the full @@ -378,17 +352,16 @@ class Banner { } #handleResize() { - this.#columns = this.#stdout.columns; + this.#columns = this.#stderr.columns; this.#render(); } #transitionTo(newState) { - if (this.#state.state === newState) { + if (this.#buildState.state === newState) { this.#render(); return; } - this.#state.state = newState; - this.#state.spinFrame = 0; + transitionTo(this.#buildState, newState); this.#clearTick(); this.#scheduleTick(); this.#render(); @@ -398,11 +371,11 @@ class Banner { if (this.#stopped) { return; } - if (this.#state.state !== STATES.BUILDING) { + if (this.#buildState.state !== STATES.BUILDING) { return; } this.#tickTimer = setInterval(() => { - this.#state.spinFrame++; + this.#buildState.spinFrame++; this.#render(); }, BUILDING_TICK_MS); // Don't keep the event loop alive purely for the banner spinner. @@ -420,11 +393,11 @@ class Banner { // ---- process.stdout / process.stderr interception ------------------------- // Custom tasks and third-party libraries sometimes bypass @ui5/logger and - // write straight to the process streams. Any such write between banner - // frames throws off log-update's line-count accounting (it counts only the - // bytes it emitted itself) and the next render corrupts — typically by + // write straight to the process streams. Any such write between frames + // throws off log-update's line-count accounting (it counts only the bytes + // it emitted itself) and the next render corrupts — typically by // duplicating the header. To keep the live region intact we replace - // process.stdout.write / process.stderr.write while the banner is active + // process.stdout.write / process.stderr.write while the writer is active // and route incoming bytes through logAbove() line by line. log-update's // own writes bypass the interception via the #renderingLiveRegion flag. @@ -450,38 +423,21 @@ class Banner { // The returned function mirrors WriteStream#write's overloads: // write(chunk[, encoding][, callback]) -> boolean return (chunk, encodingOrCallback, maybeCallback) => { - // Re-entrant writes (log-update painting the live region, or - // stop-time flushes / `done()`) must pass through untouched — - // otherwise the very act of rendering would recurse into - // logAbove. if (this.#renderingLiveRegion || this.#stopped) { - // Forward the argument list as-is so the real write() sees - // the same overload the caller intended. return original.call(stream, chunk, encodingOrCallback, maybeCallback); } const {encoding, callback} = parseWriteArgs(encodingOrCallback, maybeCallback); - // Buffer/Uint8Array both expose toString(encoding); avoid the - // extra Buffer.from() copy when chunk is already a Buffer. const text = typeof chunk === "string" ? chunk : (Buffer.isBuffer(chunk) ? chunk.toString(encoding) : Buffer.from(chunk).toString(encoding)); this.#absorbInterceptedText(text, which); - // Node's write() invokes the callback once the chunk has drained. - // Our sink is synchronous, but the callback contract still needs - // an async tick so callers don't see it fire mid-write. if (callback) { process.nextTick(callback); } - // Return `true` to signal no backpressure — the intercepted bytes - // have been fully consumed by logAbove(). return true; }; } - // Buffer intercepted bytes per stream, emitting completed lines via - // logAbove() in a single batched call. Partial (non-newline-terminated) - // tail bytes stay buffered until either the next chunk completes the - // line or stop() flushes them. #absorbInterceptedText(text, which) { const entry = this.#streams[which]; const combined = entry.partial + text; @@ -491,9 +447,6 @@ class Banner { return; } entry.partial = combined.slice(lastNewline + 1); - // Persist the whole run of completed lines in one frame rather than - // one persist+render per line — a rapid burst of output would - // otherwise trigger N re-renders. this.logAbove(combined.slice(0, lastNewline)); } @@ -508,12 +461,37 @@ class Banner { } } + /** + * Creates a new instance and subscribes it to all events. Any currently- + * active console-writing writer is displaced by the `ui5.log.stop-console` + * event that `enable()` emits. + * + * @public + */ + static init() { + const w = new InteractiveConsole(); + w.enable(); + return w; + } + + static stop() { + process.emit("ui5.log.stop-console"); + } + // ---- Test helpers --------------------------------------------------------- /* istanbul ignore next */ _getStateForTest() { - return this.#state; + return { + header: this.#headerState, + project: this.#projectState, + server: this.#serverState, + build: this.#buildState, + }; } } -export default Banner; +// Split from the constructor so the `log-update` factory is imported lazily +// and can be swapped out by tests that pass a stubbed stderr. `createLogUpdate` +// is a synchronous factory; the async import is not warranted. +export default InteractiveConsole; diff --git a/packages/logger/lib/writers/interactiveConsole/format.js b/packages/logger/lib/writers/interactiveConsole/format.js new file mode 100644 index 00000000000..229b8cfc50e --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/format.js @@ -0,0 +1,31 @@ +import chalk from "chalk"; +import figures from "figures"; + +// Low-level formatting primitives — the theme (brand and accent colors, the +// pointer arrow, placeholder styling) shared by all region renderers. + +// COLORFGBG is set by many terminals (xterm, konsole, rxvt, iTerm2, …) as +// ";" with ANSI color indices. Indices 0-6 and 8 are conventionally +// dark backgrounds. Apple Terminal, VS Code, and Windows Terminal don't set +// this — we fall back to the light-background palette in that case. +function isDarkTerminalBackground() { + const v = process.env.COLORFGBG; + if (!v) { + return false; + } + const bg = parseInt(v.split(";").pop(), 10); + if (Number.isNaN(bg)) { + return false; + } + return bg < 7 || bg === 8; +} + +const DARK_MODE = isDarkTerminalBackground(); +const BRAND_HEX = DARK_MODE ? "#FF5A37" : "#1873B4"; +const ACCENT_HEX = DARK_MODE ? "#FFA42C" : "#53B8DE"; + +export const brand = (text) => chalk.bold.hex(BRAND_HEX)(text); +export const accent = (text) => chalk.hex(ACCENT_HEX)(text); +export const accentBold = (text) => chalk.bold.hex(ACCENT_HEX)(text); +export const arrow = accent(figures.pointer); +export const placeholder = (text) => chalk.dim.italic(text); diff --git a/packages/cli/lib/serve/remoteConnectionsWarning.js b/packages/logger/lib/writers/interactiveConsole/remoteConnectionsWarning.js similarity index 81% rename from packages/cli/lib/serve/remoteConnectionsWarning.js rename to packages/logger/lib/writers/interactiveConsole/remoteConnectionsWarning.js index 5c2ba251995..6202e9e8bea 100644 --- a/packages/cli/lib/serve/remoteConnectionsWarning.js +++ b/packages/logger/lib/writers/interactiveConsole/remoteConnectionsWarning.js @@ -2,9 +2,9 @@ import chalk from "chalk"; import figures from "figures"; // Shared content for the "accepting remote connections" warning, rendered -// both by the live banner (see render.js) and by the plain stderr fallback -// in the serve command. Each entry is a fully chalk-formatted string; -// consumers just print them line by line. +// both by the interactive writer's server region and by Console.js's non-TTY +// fallback. Each entry is a fully chalk-formatted string; consumers just +// print them line by line. export const REMOTE_CONNECTIONS_WARNING_LINES = Object.freeze([ chalk.bold.yellow( `${figures.warning} This server is accepting connections from all hosts on your network`), diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js new file mode 100644 index 00000000000..5491787e8dc --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/render.js @@ -0,0 +1,147 @@ +import chalk from "chalk"; +import figures from "figures"; +import prettyHrtime from "pretty-hrtime"; +import {STATES} from "./state/build.js"; +import {brand, accent, accentBold, arrow, placeholder} from "./format.js"; +import {REMOTE_CONNECTIONS_WARNING_LINES} from "./remoteConnectionsWarning.js"; + +const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"]; +const STATE_LABEL_WIDTH = "building".length; +const pad = (s) => s.padEnd(STATE_LABEL_WIDTH); + +// Align continuation lines (additional network URLs / extra placeholders) +// under the first URL slot. The arrow, single space, and "Network: " label +// are all fixed-width. +const NETWORK_INDENT = " ".repeat(`${figures.pointer} ${"Network:"} `.length); + +// Order matters: header → project → server → build → status → log-above (log +// scrolls outside the live region). Each region emits an array of lines and +// is skipped entirely when it has no content, giving a stable layout that +// grows top-to-bottom as data arrives. + +export function renderHeaderRegion(headerState) { + if (!headerState.tool) { + return []; + } + const version = headerState.tool.version ? chalk.dim("v" + headerState.tool.version) : ""; + return [ + "", + `${brand(headerState.tool.name || "UI5 CLI")} ${version}`, + ]; +} + +export function renderProjectRegion(projectState) { + if (!projectState.project) { + return []; + } + const project = projectState.project; + const framework = projectState.framework; + const projectType = project.type ? chalk.dim(`(${project.type})`) : ""; + const projectVersion = project.version ? chalk.dim("v" + project.version) : ""; + const lines = [""]; + lines.push(`${chalk.dim("Project")} ${chalk.bold(project.name)}` + + (projectType ? ` ${projectType}` : "") + + (projectVersion ? ` ${projectVersion}` : "")); + if (framework && framework.name) { + const frameworkVersion = framework.version ? ` ${framework.version}` : ""; + lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name + frameworkVersion)}`); + } else { + lines.push(`${chalk.dim("Framework")} ${placeholder("(none)")}`); + } + return lines; +} + +export function renderServerRegion(serverState) { + if (!serverState.urls) { + return []; + } + const urls = serverState.urls; + const lines = [""]; + + // The event's `urls` list carries labels ("Local"/"Network") shaped by the + // server. Preserve the current two-line "Local" / "Network" layout by + // splitting labels here. + const local = urls.filter((u) => u.label === "Local"); + const network = urls.filter((u) => u.label === "Network"); + const other = urls.filter((u) => u.label !== "Local" && u.label !== "Network"); + + if (local.length > 0) { + lines.push(`${arrow} ${accentBold("Local:")} ${accent(local[0].url)}`); + } + if (network.length > 0) { + lines.push(`${arrow} ${accentBold("Network:")} ${accent(network[0].url)}`); + for (let i = 1; i < network.length; i++) { + lines.push(`${NETWORK_INDENT}${accent(network[i].url)}`); + } + } else if (!serverState.acceptRemoteConnections && local.length > 0) { + lines.push(`${arrow} ${accentBold("Network:")} ` + + chalk.dim("use --accept-remote-connections to expose")); + } + for (const entry of other) { + lines.push(`${arrow} ${accentBold(entry.label + ":")} ${accent(entry.url)}`); + } + + if (serverState.acceptRemoteConnections) { + lines.push(""); + for (const line of REMOTE_CONNECTIONS_WARNING_LINES) { + lines.push(line); + } + } + return lines; +} + +export function renderBuildRegion(buildState) { + if (buildState.state === STATES.INITIAL) { + return []; + } + const lines = [""]; + lines.push(renderStatusLine(buildState)); + return lines; +} + +function renderStatusLine(state) { + const label = `${chalk.dim("Status")} `; + switch (state.state) { + case STATES.READY: { + let suffix = ""; + if (state.lastBuildHrtime) { + suffix = ` ${chalk.dim("·")} ${chalk.dim("Time elapsed: " + prettyHrtime(state.lastBuildHrtime))}`; + } + return `${label}${chalk.green(figures.bullet)} ${chalk.green(pad("ready"))}${suffix}`; + } + case STATES.STALE: + return `${label}${chalk.yellow(figures.circle)} ${chalk.yellow(pad("stale"))} ` + + `${chalk.dim("· files changed, rebuild on next request")}`; + case STATES.BUILDING: { + const frame = SPINNER_FRAMES[state.spinFrame % SPINNER_FRAMES.length]; + const parts = [ + `${chalk.yellow(frame)} ${chalk.yellow(pad("building"))}`, + ]; + if (state.totalProjects > 0 && state.currentProjectIndex > 0) { + const counterWidth = String(state.totalProjects).length; + const counter = `${String(state.currentProjectIndex).padStart(counterWidth)}` + + `/${state.totalProjects} projects`; + parts.push(chalk.dim("·"), chalk.dim(counter)); + } + if (state.currentProjectName) { + const padded = state.projectNameWidth ? + state.currentProjectName.padEnd(state.projectNameWidth) : + state.currentProjectName; + parts.push(chalk.dim("·"), chalk.bold(padded)); + } + if (state.currentTaskName) { + const padded = state.taskNameWidth ? + state.currentTaskName.padEnd(state.taskNameWidth) : + state.currentTaskName; + parts.push(chalk.dim("·"), chalk.dim(padded)); + } + return `${label}${parts.join(" ")}`; + } + case STATES.ERROR: { + const msg = state.errorMessage ? ` · ${state.errorMessage}` : ""; + return `${label}${chalk.red(figures.cross)} ${chalk.red(pad("error"))}${chalk.dim(msg)}`; + } + default: + return label; + } +} diff --git a/packages/logger/lib/writers/interactiveConsole/state/build.js b/packages/logger/lib/writers/interactiveConsole/state/build.js new file mode 100644 index 00000000000..abeba068616 --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/state/build.js @@ -0,0 +1,88 @@ +// Region 5 — build. Populated by `ui5.build-metadata`, `ui5.build-status`, +// `ui5.project-build-metadata`, `ui5.project-build-status`, `ui5.serve-status`. + +export const STATES = Object.freeze({ + INITIAL: "initial", + READY: "ready", + STALE: "stale", + BUILDING: "building", + ERROR: "error", +}); + +export function createBuildState() { + return { + state: STATES.INITIAL, + // During `building`: current project counter (1-based) and total projects. + // Both are reset by `ui5.build-metadata` and by `serve-building`. + currentProjectIndex: 0, + totalProjects: 0, + currentProjectName: "", + currentTaskName: "", + // Names of projects collected via `serve-stale` payloads — used to label + // the stale state if/when the renderer wants to. + changedProjects: [], + // Frame counter for the spinner (incremented by the tick loop). + spinFrame: 0, + // Most recent error captured by `serve-error`. + errorMessage: "", + // Duration of the most recent successful build, captured from the + // `serve-build-done` event as a [seconds, nanoseconds] tuple — the same + // shape that pretty-hrtime consumes, so the renderer can hand it + // straight through. + lastBuildHrtime: null, + // Layout hints derived from build-metadata: pad the project/task columns + // so status-line updates don't reflow. + projectNameWidth: 0, + taskNameWidth: 0, + // Ordered list of projects announced by build-metadata. Used to compute + // a stable 1-based `currentProjectIndex` when build-status events arrive. + projectOrder: [], + }; +} + +// Zero the transient counters for a fresh build. Shared by `build-metadata` +// and the `serve-building` branch of `serve-status`; see doc item #7. +export function resetBuildProgress(state) { + state.currentProjectIndex = 0; + state.currentProjectName = ""; + state.currentTaskName = ""; + state.spinFrame = 0; +} + +export function beginBuild(state, projectOrder) { + state.projectOrder = Array.from(projectOrder); + state.totalProjects = state.projectOrder.length; + state.projectNameWidth = state.projectOrder.reduce( + (max, name) => Math.max(max, name.length), 0); + resetBuildProgress(state); +} + +export function advanceToProject(state, projectName) { + const idx = state.projectOrder.indexOf(projectName); + state.currentProjectIndex = idx >= 0 ? + idx + 1 : + state.currentProjectIndex + 1; + state.currentProjectName = projectName; + state.currentTaskName = ""; +} + +export function setTask(state, taskName) { + state.currentTaskName = taskName; + if (taskName.length > state.taskNameWidth) { + state.taskNameWidth = taskName.length; + } +} + +export function transitionTo(state, newState) { + state.state = newState; + state.spinFrame = 0; +} + +export function setError(state, message) { + state.errorMessage = message || ""; + transitionTo(state, STATES.ERROR); +} + +export function hasContent(state) { + return state.state !== STATES.INITIAL; +} diff --git a/packages/logger/lib/writers/interactiveConsole/state/header.js b/packages/logger/lib/writers/interactiveConsole/state/header.js new file mode 100644 index 00000000000..247a178d2d1 --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/state/header.js @@ -0,0 +1,14 @@ +// Region 1 — header. Populated by `ui5.tool-info`. Set once, never changes. +export function createHeaderState() { + return { + tool: null, // {name, version} — null while unknown + }; +} + +export function setTool(state, tool) { + state.tool = tool ? {name: tool.name, version: tool.version} : null; +} + +export function hasContent(state) { + return state.tool !== null; +} diff --git a/packages/logger/lib/writers/interactiveConsole/state/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js new file mode 100644 index 00000000000..3799c14d4e1 --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/state/project.js @@ -0,0 +1,16 @@ +// Region 2 — root project. Populated by `ui5.project-resolved`. +export function createProjectState() { + return { + project: null, // {name, type, version} + framework: null, // {name, version} | null + }; +} + +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 hasContent(state) { + return state.project !== null; +} diff --git a/packages/logger/lib/writers/interactiveConsole/state/server.js b/packages/logger/lib/writers/interactiveConsole/state/server.js new file mode 100644 index 00000000000..ee820a8f151 --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/state/server.js @@ -0,0 +1,16 @@ +// Region 4 — server. Populated by `ui5.server-listening`. +export function createServerState() { + return { + urls: null, // Array<{label, url}> | null + acceptRemoteConnections: false, + }; +} + +export function setListening(state, evt) { + state.urls = Array.isArray(evt.urls) ? evt.urls.map((u) => ({label: u.label, url: u.url})) : []; + state.acceptRemoteConnections = !!evt.acceptRemoteConnections; +} + +export function hasContent(state) { + return state.urls !== null; +} diff --git a/packages/logger/lib/writers/internal/levelPrefix.js b/packages/logger/lib/writers/internal/levelPrefix.js new file mode 100644 index 00000000000..6eccf95a36b --- /dev/null +++ b/packages/logger/lib/writers/internal/levelPrefix.js @@ -0,0 +1,25 @@ +import {chalkStderr as chalk} from "chalk"; + +// Shared level-prefix renderer used by every console-writing writer so scrolled +// log lines look identical regardless of which writer produced them. Kept as +// an internal helper module rather than a public export — the exact styling is +// an implementation detail of `writers/Console` and its siblings. +export function getLevelPrefix(level) { + switch (level) { + case "silly": + return chalk.inverse(level); + case "verbose": + return chalk.cyan("verb"); + case "perf": + return chalk.bgYellow.red(level); + case "info": + return chalk.green(level); + case "warn": + return chalk.yellow(level); + case "error": + return chalk.bgRed.white(level); + default: + // Log level silent does not produce messages + throw new Error(`writers/internal/levelPrefix: Invalid message log level "${level}"`); + } +} diff --git a/packages/logger/package.json b/packages/logger/package.json index 4f69f455d35..bdbd68dc667 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -51,7 +51,9 @@ "chalk": "^5.6.2", "cli-progress": "^3.12.0", "figures": "^6.1.0", - "pretty-hrtime": "^1.0.3" + "log-update": "^7.2.0", + "pretty-hrtime": "^1.0.3", + "slice-ansi": "^5.0.0" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/logger/test/lib/package-exports.js b/packages/logger/test/lib/package-exports.js index 87585182e33..84af8cb2300 100644 --- a/packages/logger/test/lib/package-exports.js +++ b/packages/logger/test/lib/package-exports.js @@ -19,6 +19,7 @@ test("check number of exports", (t) => { [ {exportedSpecifier: "Logger", mappedModule: "../../lib/loggers/Logger.js"}, "writers/Console", + "writers/InteractiveConsole", // Internal modules (only to be used by @ui5/* packages) {exportedSpecifier: "internal/loggers/Logger", mappedModule: "../../lib/loggers/Logger.js"}, diff --git a/packages/logger/test/lib/writers/Console.js b/packages/logger/test/lib/writers/Console.js index ca26062f5c8..4efab6edd7c 100644 --- a/packages/logger/test/lib/writers/Console.js +++ b/packages/logger/test/lib/writers/Console.js @@ -42,7 +42,7 @@ test.serial("Log event with invalid log level 'silent'", (t) => { moduleName: "my:module" }); }, { - message: `writers/Console: Invalid message log level "silent"` + message: `writers/internal/levelPrefix: Invalid message log level "silent"` }); t.is(stderrWriteStub.callCount, 0, "Logged no message"); @@ -128,6 +128,8 @@ test.serial("Stop", (t) => { test.serial("Stop disables all instances", (t) => { const {stderrWriteStub} = t.context; + // init() emits ui5.log.stop-console before enabling, so every new + // instance detaches previously-active ones. The last init() call wins. ConsoleWriter.init(); ConsoleWriter.init(); @@ -137,7 +139,7 @@ test.serial("Stop disables all instances", (t) => { moduleName: "my:module" }); - t.is(stderrWriteStub.callCount, 3, "Logged three message"); + t.is(stderrWriteStub.callCount, 1, "Only the most recently initialised writer logged"); stderrWriteStub.resetHistory(); ConsoleWriter.stop(); diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js new file mode 100644 index 00000000000..c2102112776 --- /dev/null +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -0,0 +1,272 @@ +import test from "ava"; +import sinon from "sinon"; +import {EventEmitter} from "node:events"; +import stripAnsi from "strip-ansi"; +import figures from "figures"; + +import InteractiveConsole from "../../../lib/writers/InteractiveConsole.js"; +import {STATES} from "../../../lib/writers/interactiveConsole/state/build.js"; + +function createStubStderr() { + const stub = new EventEmitter(); + stub.columns = 200; + stub.writes = []; + stub.write = (chunk) => { + stub.writes.push(chunk); + return true; + }; + return stub; +} + +// Build a writer wired to a stub stderr so tests can drive events without +// touching the real terminal. The writer is fully event-driven — tests emit +// process events to populate its regions. +function createWriter() { + const stderr = createStubStderr(); + const writer = new InteractiveConsole({stderr}); + writer.enable(); + return {writer, stderr}; +} + +test.afterEach.always(() => { + // Ensure a stray stop-console doesn't leak between tests. + process.emit("ui5.log.stop-console"); +}); + +test.serial("tool-info populates the header region", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.tool-info", {name: "UI5 CLI", version: "1.2.3"}); + + const state = writer._getStateForTest(); + t.deepEqual(state.header.tool, {name: "UI5 CLI", version: "1.2.3"}); + + writer.disable(); +}); + +test.serial("project-resolved populates the project region", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.project-resolved", { + 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"}); + + writer.disable(); +}); + +test.serial("duplicate project-resolved throws", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.project-resolved", { + name: "my.app", + type: "application", + version: "1.0.0", + framework: null, + }); + + // The writer's model is single-root-project. A second event means the + // caller violated the invariant. + t.throws(() => { + process.emit("ui5.project-resolved", { + name: "other.app", + type: "application", + version: "2.0.0", + framework: null, + }); + }, { + message: /duplicate ui5\.project-resolved/, + }); + + writer.disable(); +}); + +test.serial("server-listening populates the server region", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.server-listening", { + urls: [ + {label: "Local", url: "http://localhost:8080"}, + {label: "Network", url: "http://192.168.1.5:8080"}, + ], + acceptRemoteConnections: true, + }); + + const state = writer._getStateForTest(); + t.is(state.server.urls.length, 2); + t.is(state.server.urls[0].url, "http://localhost:8080"); + t.true(state.server.acceptRemoteConnections); + + writer.disable(); +}); + +test.serial("build-metadata + build-status advance the build region", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.build-metadata", {projectsToBuild: ["proj1", "proj2"]}); + + let state = writer._getStateForTest(); + t.is(state.build.totalProjects, 2); + t.deepEqual(state.build.projectOrder, ["proj1", "proj2"]); + + process.emit("ui5.build-status", {projectName: "proj1", status: "project-build-start"}); + state = writer._getStateForTest(); + t.is(state.build.currentProjectIndex, 1); + t.is(state.build.currentProjectName, "proj1"); + + process.emit("ui5.build-status", {projectName: "proj2", status: "project-build-start"}); + state = writer._getStateForTest(); + t.is(state.build.currentProjectIndex, 2); + t.is(state.build.currentProjectName, "proj2"); + + writer.disable(); +}); + +test.serial("serve-status: serve-ready transitions to READY", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.serve-status", {status: "serve-ready"}); + + t.is(writer._getStateForTest().build.state, STATES.READY); + writer.disable(); +}); + +test.serial("serve-status: serve-building resets progress and transitions to BUILDING", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.build-metadata", {projectsToBuild: ["proj1"]}); + process.emit("ui5.build-status", {projectName: "proj1", status: "project-build-start"}); + process.emit("ui5.serve-status", {status: "serve-building"}); + + const state = writer._getStateForTest(); + t.is(state.build.state, STATES.BUILDING); + t.is(state.build.currentProjectIndex, 0, "counter reset for new build"); + t.is(state.build.currentProjectName, "", "project name cleared for new build"); + + writer.disable(); +}); + +test.serial("serve-status: serve-error transitions to ERROR and records message", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.serve-status", { + status: "serve-error", + error: new Error("Boom"), + }); + + const state = writer._getStateForTest(); + t.is(state.build.state, STATES.ERROR); + t.is(state.build.errorMessage, "Boom"); + writer.disable(); +}); + +test.serial("regions are order-tolerant — server before project", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.server-listening", { + urls: [{label: "Local", url: "http://localhost:8080"}], + acceptRemoteConnections: false, + }); + process.emit("ui5.project-resolved", { + name: "my.app", type: "application", version: "1.0.0", framework: null, + }); + + const state = writer._getStateForTest(); + t.truthy(state.server.urls); + t.truthy(state.project.project); + + writer.disable(); +}); + +test.serial("warn / error logs scroll above the live region", (t) => { + const {writer, stderr} = createWriter(); + + process.emit("ui5.log", { + level: "warn", + message: "Something odd", + moduleName: "my:module", + }); + + const output = stripAnsi(stderr.writes.join("")); + t.regex(output, /warn my:module Something odd/); + + writer.disable(); +}); + +test.serial("info logs are filtered — status line represents build state", (t) => { + const {writer, stderr} = createWriter(); + + process.emit("ui5.log", { + level: "info", + message: "quiet info", + moduleName: "my:module", + }); + + // The persistent frame renders on info events too because #render is + // called; but no line containing the info text should scroll above. + const output = stripAnsi(stderr.writes.join("")); + t.notRegex(output, /quiet info/); + + writer.disable(); +}); + +test.serial("build-metadata a second time resets and continues (rebuild)", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.build-metadata", {projectsToBuild: ["proj1", "proj2"]}); + process.emit("ui5.build-status", {projectName: "proj1", status: "project-build-start"}); + + process.emit("ui5.build-metadata", {projectsToBuild: ["projA"]}); + + const state = writer._getStateForTest(); + t.deepEqual(state.build.projectOrder, ["projA"]); + t.is(state.build.totalProjects, 1); + t.is(state.build.currentProjectIndex, 0, "reset for rebuild"); + t.is(state.build.currentProjectName, "", "cleared for rebuild"); + + writer.disable(); +}); + +test.serial("enable() emits ui5.log.stop-console to displace prior writer", (t) => { + const stopHandler = sinon.stub(); + process.on("ui5.log.stop-console", stopHandler); + + const stderr = createStubStderr(); + const writer = new InteractiveConsole({stderr}); + writer.enable(); + + t.true(stopHandler.callCount >= 1, "stop-console emitted on enable"); + + process.off("ui5.log.stop-console", stopHandler); + writer.disable(); +}); + +test.serial("frame includes visible content for each populated region", (t) => { + const {writer, stderr} = createWriter(); + + 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.server-listening", { + urls: [{label: "Local", url: "http://localhost:8080"}], + acceptRemoteConnections: false, + }); + process.emit("ui5.serve-status", {status: "serve-ready"}); + + const output = stripAnsi(stderr.writes.join("")); + t.regex(output, /UI5 CLI v1\.2\.3/); + t.regex(output, /Project\s+my\.app/); + t.regex(output, /Framework\s+SAPUI5 1\.150\.0/); + t.regex(output, /Local:\s+http:\/\/localhost:8080/); + t.regex(output, new RegExp(`Status\\s+${figures.bullet}\\s+ready`)); + + writer.disable(); +}); diff --git a/packages/project/lib/graph/projectGraphBuilder.js b/packages/project/lib/graph/projectGraphBuilder.js index 1376d3d224f..5ecb20073dc 100644 --- a/packages/project/lib/graph/projectGraphBuilder.js +++ b/packages/project/lib/graph/projectGraphBuilder.js @@ -1,4 +1,5 @@ import path from "node:path"; +import process from "node:process"; import Module from "./Module.js"; import ProjectGraph from "./ProjectGraph.js"; import ShimCollection from "./ShimCollection.js"; @@ -138,6 +139,19 @@ async function projectGraphBuilder(nodeProvider, workspace) { }); projectGraph.addProject(rootProject); + // 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?.(); + process.emit("ui5.project-resolved", { + name: rootProject.getName(), + type: rootProject.getType(), + version: rootProject.getVersion(), + framework: frameworkName ? {name: frameworkName, version: frameworkVersion} : null, + }); + function handleExtensions(extensions) { return _handleExtensions(projectGraph, shimCollection, extensions); } diff --git a/packages/project/test/lib/graph/projectGraphBuilder.js b/packages/project/test/lib/graph/projectGraphBuilder.js index d6bfdd392a0..46cbfefabc8 100644 --- a/packages/project/test/lib/graph/projectGraphBuilder.js +++ b/packages/project/test/lib/graph/projectGraphBuilder.js @@ -965,3 +965,24 @@ test("Multiple dependencies to different module containing the same extension", "This might be caused by multiple modules containing extensions with the same name" }); }); + +test.serial("Emits ui5.project-resolved with the root project's shape", async (t) => { + const events = []; + const listener = (evt) => events.push(evt); + process.on("ui5.project-resolved", listener); + t.teardown(() => process.off("ui5.project-resolved", listener)); + + t.context.getRootNode.resolves(createNode({ + id: "id1", + name: "root.project" + })); + + await projectGraphBuilder(t.context.provider); + + t.is(events.length, 1, "ui5.project-resolved emitted exactly once"); + 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); +}); diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index 0dacdcf0f20..9d367386585 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -1,4 +1,6 @@ import {getRandomValues} from "node:crypto"; +import os from "node:os"; +import process from "node:process"; import express from "express"; import portscanner from "portscanner"; import MiddlewareManager from "./middleware/MiddlewareManager.js"; @@ -255,6 +257,22 @@ export async function serve(graph, { liveReloadHandle = attachLiveReloadServer({httpServer: server, buildServer, token: webSocketToken}); } + // Announce the bound URLs on the event bus. The server owns the network- + // interface lookup because it knows the actual bound port (which may + // differ from the requested one when changePortIfInUse is set). Consumers + // (@ui5/logger writers) shape their own display from the label/url pairs. + const protocol = h2 ? "https" : "http"; + const urls = [{label: "Local", url: `${protocol}://localhost:${port}`}]; + if (acceptRemoteConnections) { + for (const addr of _findNetworkInterfaceAddresses()) { + urls.push({label: "Network", url: `${protocol}://${addr}:${port}`}); + } + } + process.emit("ui5.server-listening", { + urls, + acceptRemoteConnections: !!acceptRemoteConnections, + }); + return { h2, port, @@ -268,3 +286,19 @@ export async function serve(graph, { } }; } + +// Collects all non-internal IPv4 addresses from the host's network interfaces +// so `ui5.server-listening` can list every reachable URL when the server binds +// to all interfaces. Returns an empty array if no suitable address is found. +function _findNetworkInterfaceAddresses() { + const interfaces = os.networkInterfaces(); + const addresses = []; + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name] ?? []) { + if (iface.family === "IPv4" && !iface.internal) { + addresses.push(iface.address); + } + } + } + return addresses; +} From 8480e6ddece9f4db7435e24e850d16c3d6ea79b8 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 11:43:55 +0200 Subject: [PATCH 04/16] feat(logger): Render InteractiveConsole placeholders up front via ui5.tool-mode The serve command now emits a `ui5.tool-mode` event before graph resolution and server startup begin. The InteractiveConsole writer uses it to pre-populate dim placeholders for the project, server, and status regions so the full frame is visible from the first paint and later events replace placeholders in place rather than growing the layout. When `--accept-remote-connections` is set, the writer reserves one 'Network:' row per non-internal IPv4 address the CLI announces. Also: - Restore the historical `Server started` / `URL:` stdout format of the Console writer for the non-interactive serve path, so scripts parsing the log lines are not disrupted. - Add a `UI5_CLI_NO_INTERACTIVE` env kill-switch to force the non-interactive logger, useful for CI and debugging. --- packages/cli/lib/cli/commands/serve.js | 34 ++++- packages/cli/lib/cli/middlewares/logger.js | 3 +- packages/logger/lib/writers/Console.js | 11 +- .../logger/lib/writers/InteractiveConsole.js | 31 ++++- .../lib/writers/interactiveConsole/render.js | 100 +++++++++----- .../writers/interactiveConsole/state/build.js | 10 ++ .../interactiveConsole/state/project.js | 10 +- .../interactiveConsole/state/server.js | 21 ++- .../test/lib/writers/InteractiveConsole.js | 130 ++++++++++++++++++ 9 files changed, 305 insertions(+), 45 deletions(-) diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index be1274c2166..30c24118898 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -1,10 +1,28 @@ import path from "node:path"; import os from "node:os"; +import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; import {applyProjectConfigOptions, applyWorkspaceOptions, dedupeArray} from "../options.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("cli:commands:serve"); +// Non-internal IPv4 address count from the host — used only as a hint on +// `ui5.tool-mode` so the interactive writer can reserve the right number of +// "Network:" placeholder rows. The authoritative URL list still comes from +// `ui5.server-listening` (which @ui5/server emits once the server is bound). +function countNetworkInterfaceAddresses() { + const interfaces = os.networkInterfaces(); + let n = 0; + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name] ?? []) { + if (iface.family === "IPv4" && !iface.internal) { + n++; + } + } + } + return n; +} + // Serve const serve = { command: "serve", @@ -134,6 +152,17 @@ serve.builder = function(cli) { }; serve.handler = async function(argv) { + // Announce the mode up front so the interactive writer can render its full + // frame (with placeholders for anything not resolved yet) before graph and + // server work begins. `networkAddressCount` is a rendering hint — the + // server's `ui5.server-listening` event supplies the authoritative URLs. + process.emit("ui5.tool-mode", { + mode: "serve", + acceptRemoteConnections: !!argv.acceptRemoteConnections, + networkAddressCount: argv.acceptRemoteConnections ? + countNetworkInterfaceAddresses() : 0, + }); + const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph"); const {serve: serverServe} = await import("@ui5/server"); const {getSslCertificate} = await import("@ui5/server/internal/sslUtil"); @@ -212,10 +241,9 @@ serve.handler = async function(argv) { reject(err); }); - const protocol = h2 ? "https" : "http"; - let browserUrl = protocol + "://localhost:" + actualPort; - if (argv.open !== undefined) { + const protocol = h2 ? "https" : "http"; + let browserUrl = protocol + "://localhost:" + actualPort; if (typeof argv.open === "string") { let relPath = argv.open || "/"; if (!relPath.startsWith("/")) { diff --git a/packages/cli/lib/cli/middlewares/logger.js b/packages/cli/lib/cli/middlewares/logger.js index 0d9ab95fb72..187ccdb31e9 100644 --- a/packages/cli/lib/cli/middlewares/logger.js +++ b/packages/cli/lib/cli/middlewares/logger.js @@ -34,7 +34,8 @@ export async function initLogger(argv) { const useInteractive = commandName === "serve" && process.stderr.isTTY === true && - !NON_INTERACTIVE_LEVELS.has(getLogLevel()); + !NON_INTERACTIVE_LEVELS.has(getLogLevel()) && + !process.env.UI5_CLI_NO_INTERACTIVE; if (useInteractive) { const {default: InteractiveConsole} = await import("@ui5/logger/writers/InteractiveConsole"); diff --git a/packages/logger/lib/writers/Console.js b/packages/logger/lib/writers/Console.js index 2776f494796..87488d2112e 100644 --- a/packages/logger/lib/writers/Console.js +++ b/packages/logger/lib/writers/Console.js @@ -452,10 +452,15 @@ class Console { `Server is accepting remote connections from all hosts on your network. ` + `This is intended for development purposes only.`); } + // Match the historical stdout output of `ui5 serve` verbatim so scripts + // and users parsing the non-interactive log lines are not disrupted: + // only the local URL is surfaced here, without a level prefix. Network + // URLs listed under `--accept-remote-connections` are intentionally + // omitted — the interactive writer is responsible for showing them. if (Array.isArray(urls)) { - for (const entry of urls) { - const label = entry?.label ? `${entry.label}: ` : ""; - this.#writeMessage("info", `Server listening on ${label}${entry?.url ?? ""}`); + const localEntry = urls.find((entry) => entry?.label === "Local") ?? urls[0]; + if (localEntry?.url) { + process.stderr.write(`Server started\nURL: ${localEntry.url}\n`); } } } diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index 34e25738fa3..6e4edf1816a 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -5,10 +5,15 @@ import chalk from "chalk"; import Logger from "../loggers/Logger.js"; import {getLevelPrefix} from "./internal/levelPrefix.js"; import {createHeaderState, setTool} from "./interactiveConsole/state/header.js"; -import {createProjectState, setProject} from "./interactiveConsole/state/project.js"; -import {createServerState, setListening} from "./interactiveConsole/state/server.js"; +import { + createProjectState, setProject, enablePlaceholders as enableProjectPlaceholders, +} from "./interactiveConsole/state/project.js"; +import { + createServerState, setListening, enablePlaceholders as enableServerPlaceholders, +} from "./interactiveConsole/state/server.js"; import { createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, STATES, + enablePlaceholders as enableBuildPlaceholders, } from "./interactiveConsole/state/build.js"; import { renderHeaderRegion, renderProjectRegion, renderServerRegion, renderBuildRegion, @@ -85,6 +90,7 @@ class InteractiveConsole { #onProjectBuildStatus; #onServeStatus; #onToolInfo; + #onToolMode; #onProjectResolved; #onServerListening; #onStopConsole; @@ -224,6 +230,7 @@ class InteractiveConsole { this.#onProjectBuildStatus = (evt) => this.#handleProjectBuildStatus(evt); this.#onServeStatus = (evt) => this.#handleServeStatus(evt); this.#onToolInfo = (evt) => this.#handleToolInfo(evt); + this.#onToolMode = (evt) => this.#handleToolMode(evt); this.#onProjectResolved = (evt) => this.#handleProjectResolved(evt); this.#onServerListening = (evt) => this.#handleServerListening(evt); this.#onStopConsole = () => this.disable(); @@ -235,6 +242,7 @@ class InteractiveConsole { process.on("ui5.project-build-status", this.#onProjectBuildStatus); process.on("ui5.serve-status", this.#onServeStatus); process.on("ui5.tool-info", this.#onToolInfo); + process.on("ui5.tool-mode", this.#onToolMode); process.on("ui5.project-resolved", this.#onProjectResolved); process.on("ui5.server-listening", this.#onServerListening); process.on("ui5.log.stop-console", this.#onStopConsole); @@ -250,6 +258,7 @@ class InteractiveConsole { process.off("ui5.project-build-status", this.#onProjectBuildStatus); process.off("ui5.serve-status", this.#onServeStatus); process.off("ui5.tool-info", this.#onToolInfo); + process.off("ui5.tool-mode", this.#onToolMode); process.off("ui5.project-resolved", this.#onProjectResolved); process.off("ui5.server-listening", this.#onServerListening); process.off("ui5.log.stop-console", this.#onStopConsole); @@ -283,6 +292,24 @@ class InteractiveConsole { this.#render(); } + // Optional up-front hint from the CLI: "I'm about to run ". The writer + // pre-populates placeholders for the sections that is expected to + // fill in, so the full frame is visible from the first paint and subsequent + // events replace placeholders in place rather than growing the layout. + // Currently only `mode: "serve"` is understood; unknown modes are ignored, + // keeping the writer forgiving as new modes are added. + #handleToolMode(evt) { + if (evt?.mode === "serve") { + enableProjectPlaceholders(this.#projectState); + enableServerPlaceholders(this.#serverState, { + acceptRemoteConnections: evt.acceptRemoteConnections, + networkAddressCount: evt.networkAddressCount, + }); + enableBuildPlaceholders(this.#buildState); + this.#render(); + } + } + #handleProjectResolved(evt) { if (this.#seenProjectResolved) { // See docs/interactive-console-writer.md § Ordering rules: the diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js index 5491787e8dc..a8ca99cf0cd 100644 --- a/packages/logger/lib/writers/interactiveConsole/render.js +++ b/packages/logger/lib/writers/interactiveConsole/render.js @@ -17,7 +17,12 @@ const NETWORK_INDENT = " ".repeat(`${figures.pointer} ${"Network:"} `.length); // Order matters: header → project → server → build → status → log-above (log // scrolls outside the live region). Each region emits an array of lines and // is skipped entirely when it has no content, giving a stable layout that -// grows top-to-bottom as data arrives. +// grows top-to-bottom as data arrives. Placeholders (activated by +// `ui5.tool-mode`) let a region occupy its final row count from the first +// frame — the layout stops growing once every region is present. +// +// Region blocks include a leading blank line, so joining them produces a +// blank separator between regions. export function renderHeaderRegion(headerState) { if (!headerState.tool) { @@ -31,56 +36,81 @@ export function renderHeaderRegion(headerState) { } export function renderProjectRegion(projectState) { - if (!projectState.project) { + if (!projectState.project && !projectState.showPlaceholders) { return []; } - const project = projectState.project; - const framework = projectState.framework; - const projectType = project.type ? chalk.dim(`(${project.type})`) : ""; - const projectVersion = project.version ? chalk.dim("v" + project.version) : ""; const lines = [""]; - lines.push(`${chalk.dim("Project")} ${chalk.bold(project.name)}` + - (projectType ? ` ${projectType}` : "") + - (projectVersion ? ` ${projectVersion}` : "")); - if (framework && framework.name) { - const frameworkVersion = framework.version ? ` ${framework.version}` : ""; - lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name + frameworkVersion)}`); + if (projectState.project) { + const project = projectState.project; + const framework = projectState.framework; + const projectType = project.type ? chalk.dim(`(${project.type})`) : ""; + const projectVersion = project.version ? chalk.dim("v" + project.version) : ""; + lines.push(`${chalk.dim("Project")} ${chalk.bold(project.name)}` + + (projectType ? ` ${projectType}` : "") + + (projectVersion ? ` ${projectVersion}` : "")); + if (framework && framework.name) { + const frameworkVersion = framework.version ? ` ${framework.version}` : ""; + lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name + frameworkVersion)}`); + } else { + lines.push(`${chalk.dim("Framework")} ${placeholder("(none)")}`); + } } else { - lines.push(`${chalk.dim("Framework")} ${placeholder("(none)")}`); + // Placeholder mode: reserve the same two rows setProject() will fill in. + lines.push(`${chalk.dim("Project")} ${placeholder("resolving…")}`); + lines.push(`${chalk.dim("Framework")} ${placeholder("resolving…")}`); } return lines; } export function renderServerRegion(serverState) { - if (!serverState.urls) { + if (!serverState.urls && !serverState.showPlaceholders) { return []; } - const urls = serverState.urls; const lines = [""]; - // The event's `urls` list carries labels ("Local"/"Network") shaped by the - // server. Preserve the current two-line "Local" / "Network" layout by - // splitting labels here. - const local = urls.filter((u) => u.label === "Local"); - const network = urls.filter((u) => u.label === "Network"); - const other = urls.filter((u) => u.label !== "Local" && u.label !== "Network"); + if (serverState.urls) { + // The event's `urls` list carries labels ("Local"/"Network") shaped by + // the server. Preserve the current two-line "Local" / "Network" layout + // by splitting labels here. + const urls = serverState.urls; + const local = urls.filter((u) => u.label === "Local"); + const network = urls.filter((u) => u.label === "Network"); + const other = urls.filter((u) => u.label !== "Local" && u.label !== "Network"); - if (local.length > 0) { - lines.push(`${arrow} ${accentBold("Local:")} ${accent(local[0].url)}`); - } - if (network.length > 0) { - lines.push(`${arrow} ${accentBold("Network:")} ${accent(network[0].url)}`); - for (let i = 1; i < network.length; i++) { - lines.push(`${NETWORK_INDENT}${accent(network[i].url)}`); + if (local.length > 0) { + lines.push(`${arrow} ${accentBold("Local:")} ${accent(local[0].url)}`); + } + if (network.length > 0) { + lines.push(`${arrow} ${accentBold("Network:")} ${accent(network[0].url)}`); + for (let i = 1; i < network.length; i++) { + lines.push(`${NETWORK_INDENT}${accent(network[i].url)}`); + } + } else if (!serverState.acceptRemoteConnections && local.length > 0) { + lines.push(`${arrow} ${accentBold("Network:")} ` + + chalk.dim("use --accept-remote-connections to expose")); + } + for (const entry of other) { + lines.push(`${arrow} ${accentBold(entry.label + ":")} ${accent(entry.url)}`); + } + } else { + // Placeholder mode: reserve one "Local:" row plus either the + // remote-connections hint (when the flag isn't set) or the number of + // "Network:" rows the caller announced via `ui5.tool-mode`. + lines.push(`${arrow} ${accentBold("Local:")} ${placeholder("binding…")}`); + if (serverState.acceptRemoteConnections) { + const rows = Math.max(1, serverState.placeholderNetworkRows); + lines.push(`${arrow} ${accentBold("Network:")} ${placeholder("binding…")}`); + for (let i = 1; i < rows; i++) { + lines.push(`${NETWORK_INDENT}${placeholder("binding…")}`); + } + } else { + lines.push(`${arrow} ${accentBold("Network:")} ` + + chalk.dim("use --accept-remote-connections to expose")); } - } else if (!serverState.acceptRemoteConnections && local.length > 0) { - lines.push(`${arrow} ${accentBold("Network:")} ` + - chalk.dim("use --accept-remote-connections to expose")); - } - for (const entry of other) { - lines.push(`${arrow} ${accentBold(entry.label + ":")} ${accent(entry.url)}`); } + // `acceptRemoteConnections` reflects user intent, so the warning appears + // from the first frame even while URLs are still placeholders. if (serverState.acceptRemoteConnections) { lines.push(""); for (const line of REMOTE_CONNECTIONS_WARNING_LINES) { @@ -102,6 +132,8 @@ export function renderBuildRegion(buildState) { function renderStatusLine(state) { const label = `${chalk.dim("Status")} `; switch (state.state) { + case STATES.STARTING: + return `${label}${chalk.dim(figures.circle)} ${chalk.dim(pad("starting"))}`; case STATES.READY: { let suffix = ""; if (state.lastBuildHrtime) { diff --git a/packages/logger/lib/writers/interactiveConsole/state/build.js b/packages/logger/lib/writers/interactiveConsole/state/build.js index abeba068616..c397f501791 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/build.js +++ b/packages/logger/lib/writers/interactiveConsole/state/build.js @@ -3,6 +3,7 @@ export const STATES = Object.freeze({ INITIAL: "initial", + STARTING: "starting", // pre-populated placeholder before the first real state arrives READY: "ready", STALE: "stale", BUILDING: "building", @@ -83,6 +84,15 @@ export function setError(state, message) { transitionTo(state, STATES.ERROR); } +// Advance the region into a "starting" placeholder state so the Status row is +// visible from the first frame. Called from `ui5.tool-mode`. Real state +// transitions (READY/BUILDING/…) replace it. +export function enablePlaceholders(state) { + if (state.state === STATES.INITIAL) { + state.state = STATES.STARTING; + } +} + export function hasContent(state) { return state.state !== STATES.INITIAL; } diff --git a/packages/logger/lib/writers/interactiveConsole/state/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js index 3799c14d4e1..a05c6ca56e8 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/project.js +++ b/packages/logger/lib/writers/interactiveConsole/state/project.js @@ -3,6 +3,10 @@ export function createProjectState() { return { project: null, // {name, type, version} framework: null, // {name, version} | null + // When true, the region renders dim "resolving…" placeholders in place + // of real data. Enabled by `ui5.tool-mode` before the graph is built so + // the layout is stable from the very first frame. + showPlaceholders: false, }; } @@ -11,6 +15,10 @@ export function setProject(state, evt) { state.framework = evt.framework ? {name: evt.framework.name, version: evt.framework.version} : null; } +export function enablePlaceholders(state) { + state.showPlaceholders = true; +} + export function hasContent(state) { - return state.project !== null; + return state.project !== null || state.showPlaceholders; } diff --git a/packages/logger/lib/writers/interactiveConsole/state/server.js b/packages/logger/lib/writers/interactiveConsole/state/server.js index ee820a8f151..f305f74f54b 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/server.js +++ b/packages/logger/lib/writers/interactiveConsole/state/server.js @@ -3,6 +3,15 @@ export function createServerState() { return { urls: null, // Array<{label, url}> | null acceptRemoteConnections: false, + // When true, the region renders "binding…" placeholders in place of real + // URLs. Enabled by `ui5.tool-mode` so the section is visible from the + // first frame; `acceptRemoteConnections`/`placeholderNetworkRows` fine- + // tune what gets reserved. + showPlaceholders: false, + // How many "Network:" rows the initial paint should reserve. Set from + // `ui5.tool-mode` when the caller already knows the host's interface + // count; the real URL list from `ui5.server-listening` replaces these. + placeholderNetworkRows: 0, }; } @@ -11,6 +20,16 @@ export function setListening(state, evt) { state.acceptRemoteConnections = !!evt.acceptRemoteConnections; } +export function enablePlaceholders(state, {acceptRemoteConnections, networkAddressCount} = {}) { + state.showPlaceholders = true; + if (typeof acceptRemoteConnections === "boolean") { + state.acceptRemoteConnections = acceptRemoteConnections; + } + if (typeof networkAddressCount === "number") { + state.placeholderNetworkRows = networkAddressCount; + } +} + export function hasContent(state) { - return state.urls !== null; + return state.urls !== null || state.showPlaceholders; } diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index c2102112776..2d2c774c337 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -270,3 +270,133 @@ test.serial("frame includes visible content for each populated region", (t) => { writer.disable(); }); + +test.serial("tool-mode 'serve' enables placeholders for project, server, and status regions", (t) => { + const {writer, stderr} = createWriter(); + + process.emit("ui5.tool-info", {name: "UI5 CLI", version: "1.2.3"}); + process.emit("ui5.tool-mode", {mode: "serve", acceptRemoteConnections: false}); + + const output = stripAnsi(stderr.writes.join("")); + t.regex(output, /UI5 CLI v1\.2\.3/, "header rendered"); + t.regex(output, /Project\s+resolving…/, "project placeholder rendered"); + t.regex(output, /Framework\s+resolving…/, "framework placeholder rendered"); + t.regex(output, /Local:\s+binding…/, "server local placeholder rendered"); + t.regex(output, /Network:\s+use --accept-remote-connections to expose/, + "network hint rendered when the flag isn't set"); + t.regex(output, /Status\s+\S+\s+starting/, "status starting placeholder rendered"); + + const state = writer._getStateForTest(); + t.true(state.project.showPlaceholders); + t.true(state.server.showPlaceholders); + t.is(state.build.state, STATES.STARTING); + + writer.disable(); +}); + +test.serial("tool-mode 'serve' with acceptRemoteConnections reserves network placeholder rows", (t) => { + const {writer, stderr} = createWriter(); + + process.emit("ui5.tool-info", {name: "UI5 CLI", version: "1.2.3"}); + process.emit("ui5.tool-mode", { + mode: "serve", + acceptRemoteConnections: true, + networkAddressCount: 2, + }); + + const output = stripAnsi(stderr.writes.join("")); + // Two "binding…" URL placeholders on Network — one for each announced address + const bindingCount = (output.match(/binding…/g) || []).length; + t.is(bindingCount, 3, "one Local placeholder + two Network placeholders"); + t.regex(output, /accepting connections from all hosts/, + "remote-connections warning rendered up front"); + + writer.disable(); +}); + +test.serial("tool-mode 'serve' placeholders are replaced by real data", (t) => { + const {writer, stderr} = createWriter(); + + process.emit("ui5.tool-info", {name: "UI5 CLI", version: "1.2.3"}); + process.emit("ui5.tool-mode", {mode: "serve", acceptRemoteConnections: false}); + + const midOutput = stripAnsi(stderr.writes.join("")); + t.regex(midOutput, /resolving…/, "placeholders visible before real data"); + t.regex(midOutput, /binding…/); + t.regex(midOutput, /starting/); + + process.emit("ui5.project-resolved", { + name: "my.app", type: "application", version: "1.0.0", + framework: {name: "SAPUI5", version: "1.150.0"}, + }); + process.emit("ui5.server-listening", { + urls: [{label: "Local", url: "http://localhost:8080"}], + acceptRemoteConnections: false, + }); + process.emit("ui5.serve-status", {status: "serve-ready"}); + + // 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.truthy(state.server.urls); + t.is(state.build.state, STATES.READY); + + writer.disable(); +}); + +test.serial("tool-mode with an unknown mode is ignored", (t) => { + const {writer} = createWriter(); + + process.emit("ui5.tool-mode", {mode: "future-mode"}); + + const state = writer._getStateForTest(); + t.false(state.project.showPlaceholders); + t.false(state.server.showPlaceholders); + t.is(state.build.state, STATES.INITIAL); + + writer.disable(); +}); + +test.serial("region blocks are separated by a blank line in the composed frame", async (t) => { + const [ + {renderHeaderRegion, renderProjectRegion, renderServerRegion, renderBuildRegion}, + {createHeaderState, setTool}, + {createProjectState, setProject}, + {createServerState, setListening}, + {createBuildState, transitionTo, STATES: BUILD_STATES}, + ] = await Promise.all([ + import("../../../lib/writers/interactiveConsole/render.js"), + import("../../../lib/writers/interactiveConsole/state/header.js"), + import("../../../lib/writers/interactiveConsole/state/project.js"), + import("../../../lib/writers/interactiveConsole/state/server.js"), + import("../../../lib/writers/interactiveConsole/state/build.js"), + ]); + + const header = createHeaderState(); + 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, + }); + const server = createServerState(); + setListening(server, { + urls: [{label: "Local", url: "http://localhost:8080"}], + acceptRemoteConnections: false, + }); + const build = createBuildState(); + transitionTo(build, BUILD_STATES.READY); + + const frame = stripAnsi([ + ...renderHeaderRegion(header), + ...renderProjectRegion(project), + ...renderServerRegion(server), + ...renderBuildRegion(build), + ].join("\n")); + + // Blank line between header (UI5 CLI …) and project block + t.regex(frame, /UI5 CLI v1\.2\.3\n\nProject/); + // Blank line between project/framework block and server block + t.regex(frame, /Framework\s+\(none\)\n\n.*Local:/); + // Blank line between server block and status + t.regex(frame, /Network:.*\n\nStatus/); +}); From fb97d3417e33b791f16064881834d994e193f9c8 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 12:07:38 +0200 Subject: [PATCH 05/16] fix(logger): Prevent InteractiveConsole from eating scrollback on frame growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log-update v7.2.0's diff-patch path leaves the cursor one or more rows above where it thinks it is when the new frame extends past the previous one — its buildPatch clamps the pre-write cursor move at 0 instead of moving DOWN to the new start row. The next persist()/clear() then calls eraseLines() with a count based on the new frame, erasing rows that overlap the scrollback above the live region (typically the shell prompt line where the user typed 'ui5 serve'). Sidestep the bug by tracking the composed frame's line count and forcing log-update onto its first-frame write path (via clear()) whenever the row count changes. Steady-state renders (spinner ticks, in-place updates with unchanged row count) still take the fast diff path. --- .../logger/lib/writers/InteractiveConsole.js | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index 6e4edf1816a..666ee54b96b 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -67,6 +67,10 @@ class InteractiveConsole { // `#render()` call hands it the full multi-line frame and it diffs against // the previous one, erasing wrap-correctly. #logUpdate; + // Line count of the most recently composed frame — used to detect + // growing/shrinking frames and force `log-update` off its diff path in + // that case. See `#render()`. + #lastFrameLineCount; // True while a live-region render (or explicit persist/done) is in flight, // so #interceptWrite lets log-update's own bytes through to the real @@ -185,7 +189,26 @@ class InteractiveConsole { if (!this.#logUpdate) { return; } - this.#withRenderingGuard(() => this.#logUpdate(this.#composeLiveRegion())); + this.#withRenderingGuard(() => { + const frame = this.#composeLiveRegion(); + // Work around a `log-update` bug (v7.2.0): when a frame grows past + // the previous one, its `buildPatch` clamps the pre-write cursor + // move at 0 instead of moving DOWN to the new start row, so the + // cursor ends up above where the library thinks it is. The next + // `persist()`/`clear()` then calls `eraseLines()` with a count + // based on the new frame — erasing rows that overlap the scrollback + // above the live region (e.g. the shell prompt line). Forcing the + // first-frame code path by calling `clear()` whenever the row + // count changes sidesteps the diff logic entirely for the cases + // where it's buggy; steady-state renders (spinner tick, in-place + // updates with unchanged row count) still take the fast diff path. + const lineCount = frame === "" ? 0 : frame.split("\n").length; + if (this.#lastFrameLineCount !== undefined && lineCount !== this.#lastFrameLineCount) { + this.#logUpdate.clear(); + } + this.#lastFrameLineCount = lineCount; + this.#logUpdate(frame); + }); } /** @@ -205,7 +228,14 @@ class InteractiveConsole { } this.#withRenderingGuard(() => { this.#logUpdate.persist(line); - this.#logUpdate(this.#composeLiveRegion()); + // `persist()` resets log-update's internal state, so the follow-up + // render is a fresh first-frame write. Reset our own line-count + // tracker to match — otherwise the next `#render()` would + // mistakenly compare against the pre-persist count. + this.#lastFrameLineCount = undefined; + const frame = this.#composeLiveRegion(); + this.#lastFrameLineCount = frame === "" ? 0 : frame.split("\n").length; + this.#logUpdate(frame); }); } From 0744aa2b9f87cbfc50726b6f93fc9133c502756c Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 12:16:49 +0200 Subject: [PATCH 06/16] refactor(logger): Drop networkAddressCount hint from ui5.tool-mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The InteractiveConsole writer no longer accepts a networkAddressCount to reserve extra Network: placeholder rows up front. In placeholder mode the server region now paints a single "Network: binding…" row when --accept-remote-connections is set; if the real URL list from ui5.server-listening contains more addresses, the frame simply grows by those additional rows. This is an uncommon case (multiple non-internal IPv4 interfaces) and keeping the hint out of the writer avoids either pushing network-interface enumeration into the serve command handler or exposing the address count from @ui5/server. --- packages/cli/lib/cli/commands/serve.js | 27 +++---------------- .../logger/lib/writers/InteractiveConsole.js | 1 - .../lib/writers/interactiveConsole/render.js | 9 +++---- .../interactiveConsole/state/server.js | 12 ++------- .../test/lib/writers/InteractiveConsole.js | 7 +++-- 5 files changed, 12 insertions(+), 44 deletions(-) diff --git a/packages/cli/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index 30c24118898..3a21e227b7f 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -6,23 +6,6 @@ import {applyProjectConfigOptions, applyWorkspaceOptions, dedupeArray} from "../ import {getLogger} from "@ui5/logger"; const log = getLogger("cli:commands:serve"); -// Non-internal IPv4 address count from the host — used only as a hint on -// `ui5.tool-mode` so the interactive writer can reserve the right number of -// "Network:" placeholder rows. The authoritative URL list still comes from -// `ui5.server-listening` (which @ui5/server emits once the server is bound). -function countNetworkInterfaceAddresses() { - const interfaces = os.networkInterfaces(); - let n = 0; - for (const name of Object.keys(interfaces)) { - for (const iface of interfaces[name] ?? []) { - if (iface.family === "IPv4" && !iface.internal) { - n++; - } - } - } - return n; -} - // Serve const serve = { command: "serve", @@ -154,18 +137,14 @@ serve.builder = function(cli) { serve.handler = async function(argv) { // Announce the mode up front so the interactive writer can render its full // frame (with placeholders for anything not resolved yet) before graph and - // server work begins. `networkAddressCount` is a rendering hint — the - // server's `ui5.server-listening` event supplies the authoritative URLs. + // server work begins. The server's `ui5.server-listening` event later + // supplies the authoritative URLs. process.emit("ui5.tool-mode", { mode: "serve", acceptRemoteConnections: !!argv.acceptRemoteConnections, - networkAddressCount: argv.acceptRemoteConnections ? - countNetworkInterfaceAddresses() : 0, }); const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph"); - const {serve: serverServe} = await import("@ui5/server"); - const {getSslCertificate} = await import("@ui5/server/internal/sslUtil"); let graph; if (argv.dependencyDefinition) { @@ -231,12 +210,14 @@ serve.handler = async function(argv) { }; if (serverConfig.h2) { + const {getSslCertificate} = await import("@ui5/server/internal/sslUtil"); const {key, cert} = await getSslCertificate(serverConfig.key, serverConfig.cert); serverConfig.key = key; serverConfig.cert = cert; } const {promise: pOnError, reject} = Promise.withResolvers(); + const {serve: serverServe} = await import("@ui5/server"); const {h2, port: actualPort} = await serverServe(graph, serverConfig, function(err) { reject(err); }); diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index 666ee54b96b..80756429e96 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -333,7 +333,6 @@ class InteractiveConsole { enableProjectPlaceholders(this.#projectState); enableServerPlaceholders(this.#serverState, { acceptRemoteConnections: evt.acceptRemoteConnections, - networkAddressCount: evt.networkAddressCount, }); enableBuildPlaceholders(this.#buildState); this.#render(); diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js index a8ca99cf0cd..1ecfe7f4def 100644 --- a/packages/logger/lib/writers/interactiveConsole/render.js +++ b/packages/logger/lib/writers/interactiveConsole/render.js @@ -94,15 +94,12 @@ export function renderServerRegion(serverState) { } } else { // Placeholder mode: reserve one "Local:" row plus either the - // remote-connections hint (when the flag isn't set) or the number of - // "Network:" rows the caller announced via `ui5.tool-mode`. + // remote-connections hint (when the flag isn't set) or a single + // "Network:" placeholder. The frame may grow by additional rows once + // the real URL list arrives; that's acceptable for this uncommon case. lines.push(`${arrow} ${accentBold("Local:")} ${placeholder("binding…")}`); if (serverState.acceptRemoteConnections) { - const rows = Math.max(1, serverState.placeholderNetworkRows); lines.push(`${arrow} ${accentBold("Network:")} ${placeholder("binding…")}`); - for (let i = 1; i < rows; i++) { - lines.push(`${NETWORK_INDENT}${placeholder("binding…")}`); - } } else { lines.push(`${arrow} ${accentBold("Network:")} ` + chalk.dim("use --accept-remote-connections to expose")); diff --git a/packages/logger/lib/writers/interactiveConsole/state/server.js b/packages/logger/lib/writers/interactiveConsole/state/server.js index f305f74f54b..beb3d1ebe73 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/server.js +++ b/packages/logger/lib/writers/interactiveConsole/state/server.js @@ -5,13 +5,8 @@ export function createServerState() { acceptRemoteConnections: false, // When true, the region renders "binding…" placeholders in place of real // URLs. Enabled by `ui5.tool-mode` so the section is visible from the - // first frame; `acceptRemoteConnections`/`placeholderNetworkRows` fine- - // tune what gets reserved. + // first frame; `acceptRemoteConnections` fine-tunes what gets reserved. showPlaceholders: false, - // How many "Network:" rows the initial paint should reserve. Set from - // `ui5.tool-mode` when the caller already knows the host's interface - // count; the real URL list from `ui5.server-listening` replaces these. - placeholderNetworkRows: 0, }; } @@ -20,14 +15,11 @@ export function setListening(state, evt) { state.acceptRemoteConnections = !!evt.acceptRemoteConnections; } -export function enablePlaceholders(state, {acceptRemoteConnections, networkAddressCount} = {}) { +export function enablePlaceholders(state, {acceptRemoteConnections} = {}) { state.showPlaceholders = true; if (typeof acceptRemoteConnections === "boolean") { state.acceptRemoteConnections = acceptRemoteConnections; } - if (typeof networkAddressCount === "number") { - state.placeholderNetworkRows = networkAddressCount; - } } export function hasContent(state) { diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index 2d2c774c337..9d2c104bf8b 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -294,20 +294,19 @@ test.serial("tool-mode 'serve' enables placeholders for project, server, and sta writer.disable(); }); -test.serial("tool-mode 'serve' with acceptRemoteConnections reserves network placeholder rows", (t) => { +test.serial("tool-mode 'serve' with acceptRemoteConnections shows a Network placeholder", (t) => { const {writer, stderr} = createWriter(); process.emit("ui5.tool-info", {name: "UI5 CLI", version: "1.2.3"}); process.emit("ui5.tool-mode", { mode: "serve", acceptRemoteConnections: true, - networkAddressCount: 2, }); const output = stripAnsi(stderr.writes.join("")); - // Two "binding…" URL placeholders on Network — one for each announced address + // One Local placeholder + one Network placeholder const bindingCount = (output.match(/binding…/g) || []).length; - t.is(bindingCount, 3, "one Local placeholder + two Network placeholders"); + t.is(bindingCount, 2, "one Local placeholder + one Network placeholder"); t.regex(output, /accepting connections from all hosts/, "remote-connections warning rendered up front"); From 345192316b019c9ebc5be90aece25313cdc00896 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 12:30:00 +0200 Subject: [PATCH 07/16] fix(logger): Restore non-interactive ui5 serve output shape The Console writer's ui5.server-listening handler wrote 'Server started' / 'URL:' to stderr and reformatted the remote-connections warning as a single warn-prefixed line. Both diverged from the pre-banner output that scripts and pipes may rely on. Route the URL lines back to stdout, and emit the warning as the original multi-line, fully chalk-formatted block on stderr with surrounding blank lines - reusing the REMOTE_CONNECTIONS_WARNING_LINES constant already shared with the interactive writer. --- packages/logger/lib/writers/Console.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/logger/lib/writers/Console.js b/packages/logger/lib/writers/Console.js index 87488d2112e..ab08340241d 100644 --- a/packages/logger/lib/writers/Console.js +++ b/packages/logger/lib/writers/Console.js @@ -4,6 +4,7 @@ import figures from "figures"; import {MultiBar} from "cli-progress"; import Logger from "../loggers/Logger.js"; import {getLevelPrefix} from "./internal/levelPrefix.js"; +import {REMOTE_CONNECTIONS_WARNING_LINES} from "./interactiveConsole/remoteConnectionsWarning.js"; /** * Standard handler for events emitted by @ui5/logger modules. Writes messages to @@ -445,12 +446,16 @@ class Console { #handleServerListeningEvent({urls, acceptRemoteConnections}) { if (acceptRemoteConnections) { - // The interactive writer renders the remote-connections warning as a - // dedicated block. The plain writer emits it as a warning line so it - // still surfaces in non-TTY logs and pipes. - this.#writeMessage("warn", - `Server is accepting remote connections from all hosts on your network. ` + - `This is intended for development purposes only.`); + // Preserve the pre-banner output shape: the warning block goes to + // stderr as fully chalk-formatted lines, with surrounding blank + // lines, and without a `warn` level prefix. The interactive writer + // renders the same lines inside its server region. + process.stderr.write("\n"); + for (const line of REMOTE_CONNECTIONS_WARNING_LINES) { + process.stderr.write(line); + process.stderr.write("\n"); + } + process.stderr.write("\n"); } // Match the historical stdout output of `ui5 serve` verbatim so scripts // and users parsing the non-interactive log lines are not disrupted: @@ -460,7 +465,7 @@ class Console { if (Array.isArray(urls)) { const localEntry = urls.find((entry) => entry?.label === "Local") ?? urls[0]; if (localEntry?.url) { - process.stderr.write(`Server started\nURL: ${localEntry.url}\n`); + process.stdout.write(`Server started\nURL: ${localEntry.url}\n`); } } } From f4a25a92133d1a7733bb4565d4e88c308317d5b4 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 12:31:40 +0200 Subject: [PATCH 08/16] build: Update package.json and lockfile --- package-lock.json | 7 +++---- packages/cli/package.json | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 89e5bc98280..33bc3a7e501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18353,14 +18353,11 @@ "@ui5/server": "^5.0.0-alpha.5", "chalk": "^5.6.2", "data-with-position": "^0.5.0", - "figures": "^6.1.0", "import-local": "^3.2.0", "js-yaml": "^4.2.0", - "log-update": "^7.2.0", "open": "^11.0.0", "pretty-hrtime": "^1.0.3", "semver": "^7.8.5", - "slice-ansi": "^5.0.0", "update-notifier": "^7.3.1", "yargs": "^18.0.0" }, @@ -18476,7 +18473,9 @@ "chalk": "^5.6.2", "cli-progress": "^3.12.0", "figures": "^6.1.0", - "pretty-hrtime": "^1.0.3" + "log-update": "^7.2.0", + "pretty-hrtime": "^1.0.3", + "slice-ansi": "^5.0.0" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3229b04d2b2..bff07cc65f3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,7 +58,6 @@ "@ui5/server": "^5.0.0-alpha.5", "chalk": "^5.6.2", "data-with-position": "^0.5.0", - "figures": "^6.1.0", "import-local": "^3.2.0", "js-yaml": "^4.2.0", "open": "^11.0.0", From 6acb7ba0a3808a0c42fd9107c8c9cc88b52357aa Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 12:40:48 +0200 Subject: [PATCH 09/16] refactor(cli): Only emit ui5.tool-info when InteractiveConsole is used The event is consumed exclusively by InteractiveConsole's header region. ConsoleWriter has no listener for it, so emitting on that path was dead work and the comment ("or scrollback line") described behavior that does not exist. --- packages/cli/lib/cli/middlewares/logger.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/cli/lib/cli/middlewares/logger.js b/packages/cli/lib/cli/middlewares/logger.js index 187ccdb31e9..63f85ca6a2a 100644 --- a/packages/cli/lib/cli/middlewares/logger.js +++ b/packages/cli/lib/cli/middlewares/logger.js @@ -40,18 +40,16 @@ export async function initLogger(argv) { if (useInteractive) { const {default: InteractiveConsole} = await import("@ui5/logger/writers/InteractiveConsole"); InteractiveConsole.init(); + // Populate the interactive writer's header region before any command + // work starts, so the tool identity is visible from the first frame. + process.emit("ui5.tool-info", { + name: "UI5 CLI", + version: getVersion() || "", + }); } else { ConsoleWriter.init(); } - // Announce the CLI as soon as a writer is attached, so its header region - // (or scrollback line) shows the tool identity before any command work - // starts. - process.emit("ui5.tool-info", { - name: "UI5 CLI", - version: getVersion() || "", - }); - if (isLogLevelEnabled("verbose")) { const log = getLogger("cli:middlewares:base"); log.verbose(`using @ui5/cli version ${getVersionWithLocation()}`); From 9bbeea3029d6c964ab187ee03f25402162571078 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 12:42:24 +0200 Subject: [PATCH 10/16] docs(logger): Reword Console.js comments to not reference history --- packages/logger/lib/writers/Console.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/logger/lib/writers/Console.js b/packages/logger/lib/writers/Console.js index ab08340241d..b9587aa864e 100644 --- a/packages/logger/lib/writers/Console.js +++ b/packages/logger/lib/writers/Console.js @@ -446,10 +446,9 @@ class Console { #handleServerListeningEvent({urls, acceptRemoteConnections}) { if (acceptRemoteConnections) { - // Preserve the pre-banner output shape: the warning block goes to - // stderr as fully chalk-formatted lines, with surrounding blank - // lines, and without a `warn` level prefix. The interactive writer - // renders the same lines inside its server region. + // Emit the warning as chalk-formatted lines on stderr, surrounded + // by blank lines and without a `warn` level prefix. The interactive + // writer renders the same lines inside its server region. process.stderr.write("\n"); for (const line of REMOTE_CONNECTIONS_WARNING_LINES) { process.stderr.write(line); @@ -457,11 +456,11 @@ class Console { } process.stderr.write("\n"); } - // Match the historical stdout output of `ui5 serve` verbatim so scripts - // and users parsing the non-interactive log lines are not disrupted: - // only the local URL is surfaced here, without a level prefix. Network - // URLs listed under `--accept-remote-connections` are intentionally - // omitted — the interactive writer is responsible for showing them. + // Emit `Server started` and the local URL on stdout without a level + // prefix — this exact shape is a contract for scripts and users + // parsing the non-interactive log lines. Network URLs listed under + // `--accept-remote-connections` are intentionally omitted; the + // interactive writer is responsible for showing them. if (Array.isArray(urls)) { const localEntry = urls.find((entry) => entry?.label === "Local") ?? urls[0]; if (localEntry?.url) { From 63b48a457d70adbba6b763aa088d021712ebde4e Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 12:47:34 +0200 Subject: [PATCH 11/16] docs(documentation): Update ui5 serve banner status text and TTY detail Match the interactive console banner: use a middle dot separator in the stale-state description, and note that the TTY check now runs against stderr rather than stdout. --- internal/documentation/docs/pages/Server.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/documentation/docs/pages/Server.md b/internal/documentation/docs/pages/Server.md index 04a766bc8f3..a6a8637493b 100644 --- a/internal/documentation/docs/pages/Server.md +++ b/internal/documentation/docs/pages/Server.md @@ -28,14 +28,14 @@ Please be aware of the following risks when using the server: When started in an interactive terminal, `ui5 serve` renders a live status banner that shows the server URLs, the root project's name/type/version, the configured UI5 framework, and a single-line status indicator that cycles between three states as you work: - **● ready** — the server is idle; no source changes are pending and no build is in flight. -- **○ stale — files changed, waiting to rebuild** — one or more watched source files changed; the next request will trigger a rebuild. +- **○ stale · files changed, rebuild on next request** — one or more watched source files changed; the next request will trigger a rebuild. - **◐ building — *N*/*M* projects · *current project* · *current task*** — a build cycle is running. The counter, project name, and task name update in place. The status line stays pinned at the bottom of the terminal; warnings and errors scroll above it and remain in your terminal scrollback. While the banner is active, `info`-level log messages are suppressed — the status line already shows what they would report. The header repaints in place as more information becomes known (project graph resolved, server bound), so early frames may show dim placeholders for sections that have not been populated yet. The banner is automatically disabled and `ui5 serve` falls back to plain log output when: -- `stdout` is not a TTY (e.g. output is piped to a file or another process). +- `stderr` is not a TTY (e.g. output is piped to a file or another process). - The log level is set to `--silent`, `--perf`, `--verbose`, or `--silly` — these levels emit per-task chatter that the status line cannot represent. In every other case (the default `info` level, `--loglevel warn`, and `--loglevel error`), the banner is active. From c2bbc5cc03e7edbd40a070e1de01eb84a28d1890 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 13:11:36 +0200 Subject: [PATCH 12/16] refactor(logger): Simplify InteractiveConsole render path - Drop unused hasContent() exports from all four state modules; region visibility is decided by render*Region returning []. - Un-export resetBuildProgress(); only used inside build.js. - Return line count from #composeLiveRegion instead of re-splitting the joined frame in both #render and logAbove. - Replace nested for-of in #composeLiveRegion with a flat spread. - Drop unreachable #logUpdate null-guard in #render; enable() always sets it before listeners can fire. - Replace three sequential .filter passes in renderServerRegion with a single partitioning loop. - Collapse renderBuildRegion's 3-line array builder into one return. --- .../logger/lib/writers/InteractiveConsole.js | 38 ++++++++----------- .../lib/writers/interactiveConsole/render.js | 20 ++++++---- .../writers/interactiveConsole/state/build.js | 6 +-- .../interactiveConsole/state/header.js | 4 -- .../interactiveConsole/state/project.js | 4 -- .../interactiveConsole/state/server.js | 4 -- 6 files changed, 29 insertions(+), 47 deletions(-) diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index 80756429e96..cb655a0329b 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -154,23 +154,19 @@ class InteractiveConsole { this.#stderr.write("\n"); } - // Compose the full live region as a single string: one block per region, - // separated by their own leading blank line. `log-update` handles wrapping - // and erase of the previous frame, so we just hand it the full text. + // Compose the full live region as a single string plus its line count. + // One block per region, separated by their own leading blank line. + // `log-update` handles wrapping and erase of the previous frame, so we + // just hand it the full text. #composeLiveRegion() { - const lines = []; - for (const region of [ - renderHeaderRegion(this.#headerState), - renderProjectRegion(this.#projectState), - renderServerRegion(this.#serverState), - renderBuildRegion(this.#buildState), - ]) { - for (const line of region) { - lines.push(line); - } - } + const lines = [ + ...renderHeaderRegion(this.#headerState), + ...renderProjectRegion(this.#projectState), + ...renderServerRegion(this.#serverState), + ...renderBuildRegion(this.#buildState), + ]; if (lines.length === 0) { - return ""; + return {frame: "", lineCount: 0}; } // `log-update` wraps long lines onto additional rows; the last line is // designed to fit on a single row when it's a status line, so clip it @@ -179,18 +175,15 @@ class InteractiveConsole { if (this.#buildState.state !== STATES.INITIAL) { lines[lines.length - 1] = sliceAnsi(lines[lines.length - 1], 0, this.#columns); } - return lines.join("\n"); + return {frame: lines.join("\n"), lineCount: lines.length}; } #render() { if (this.#stopped) { return; } - if (!this.#logUpdate) { - return; - } this.#withRenderingGuard(() => { - const frame = this.#composeLiveRegion(); + const {frame, lineCount} = this.#composeLiveRegion(); // Work around a `log-update` bug (v7.2.0): when a frame grows past // the previous one, its `buildPatch` clamps the pre-write cursor // move at 0 instead of moving DOWN to the new start row, so the @@ -202,7 +195,6 @@ class InteractiveConsole { // count changes sidesteps the diff logic entirely for the cases // where it's buggy; steady-state renders (spinner tick, in-place // updates with unchanged row count) still take the fast diff path. - const lineCount = frame === "" ? 0 : frame.split("\n").length; if (this.#lastFrameLineCount !== undefined && lineCount !== this.#lastFrameLineCount) { this.#logUpdate.clear(); } @@ -233,8 +225,8 @@ class InteractiveConsole { // tracker to match — otherwise the next `#render()` would // mistakenly compare against the pre-persist count. this.#lastFrameLineCount = undefined; - const frame = this.#composeLiveRegion(); - this.#lastFrameLineCount = frame === "" ? 0 : frame.split("\n").length; + const {frame, lineCount} = this.#composeLiveRegion(); + this.#lastFrameLineCount = lineCount; this.#logUpdate(frame); }); } diff --git a/packages/logger/lib/writers/interactiveConsole/render.js b/packages/logger/lib/writers/interactiveConsole/render.js index 1ecfe7f4def..2aa2d2322a3 100644 --- a/packages/logger/lib/writers/interactiveConsole/render.js +++ b/packages/logger/lib/writers/interactiveConsole/render.js @@ -72,10 +72,18 @@ export function renderServerRegion(serverState) { // The event's `urls` list carries labels ("Local"/"Network") shaped by // the server. Preserve the current two-line "Local" / "Network" layout // by splitting labels here. - const urls = serverState.urls; - const local = urls.filter((u) => u.label === "Local"); - const network = urls.filter((u) => u.label === "Network"); - const other = urls.filter((u) => u.label !== "Local" && u.label !== "Network"); + const local = []; + const network = []; + const other = []; + for (const entry of serverState.urls) { + if (entry.label === "Local") { + local.push(entry); + } else if (entry.label === "Network") { + network.push(entry); + } else { + other.push(entry); + } + } if (local.length > 0) { lines.push(`${arrow} ${accentBold("Local:")} ${accent(local[0].url)}`); @@ -121,9 +129,7 @@ export function renderBuildRegion(buildState) { if (buildState.state === STATES.INITIAL) { return []; } - const lines = [""]; - lines.push(renderStatusLine(buildState)); - return lines; + return ["", renderStatusLine(buildState)]; } function renderStatusLine(state) { diff --git a/packages/logger/lib/writers/interactiveConsole/state/build.js b/packages/logger/lib/writers/interactiveConsole/state/build.js index c397f501791..906376dbefa 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/build.js +++ b/packages/logger/lib/writers/interactiveConsole/state/build.js @@ -43,7 +43,7 @@ export function createBuildState() { // Zero the transient counters for a fresh build. Shared by `build-metadata` // and the `serve-building` branch of `serve-status`; see doc item #7. -export function resetBuildProgress(state) { +function resetBuildProgress(state) { state.currentProjectIndex = 0; state.currentProjectName = ""; state.currentTaskName = ""; @@ -92,7 +92,3 @@ export function enablePlaceholders(state) { state.state = STATES.STARTING; } } - -export function hasContent(state) { - return state.state !== STATES.INITIAL; -} diff --git a/packages/logger/lib/writers/interactiveConsole/state/header.js b/packages/logger/lib/writers/interactiveConsole/state/header.js index 247a178d2d1..1f699c0251d 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/header.js +++ b/packages/logger/lib/writers/interactiveConsole/state/header.js @@ -8,7 +8,3 @@ export function createHeaderState() { export function setTool(state, tool) { state.tool = tool ? {name: tool.name, version: tool.version} : null; } - -export function hasContent(state) { - return state.tool !== null; -} diff --git a/packages/logger/lib/writers/interactiveConsole/state/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js index a05c6ca56e8..9cde3067be9 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/project.js +++ b/packages/logger/lib/writers/interactiveConsole/state/project.js @@ -18,7 +18,3 @@ export function setProject(state, evt) { export function enablePlaceholders(state) { state.showPlaceholders = true; } - -export function hasContent(state) { - return state.project !== null || state.showPlaceholders; -} diff --git a/packages/logger/lib/writers/interactiveConsole/state/server.js b/packages/logger/lib/writers/interactiveConsole/state/server.js index beb3d1ebe73..bf1dc9b6876 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/server.js +++ b/packages/logger/lib/writers/interactiveConsole/state/server.js @@ -21,7 +21,3 @@ export function enablePlaceholders(state, {acceptRemoteConnections} = {}) { state.acceptRemoteConnections = acceptRemoteConnections; } } - -export function hasContent(state) { - return state.urls !== null || state.showPlaceholders; -} From fbe4023fcf08a8c7405bca27a60204f596353e14 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 13:16:32 +0200 Subject: [PATCH 13/16] refactor(logger): Rename enablePlaceholders per state region Give each region's placeholder-enabling function a unique name (enableProjectPlaceholders / enableServerPlaceholders / enableBuildPlaceholders) so InteractiveConsole.js can import them directly without `as` aliases. --- packages/logger/lib/writers/InteractiveConsole.js | 10 +++------- .../lib/writers/interactiveConsole/state/build.js | 2 +- .../lib/writers/interactiveConsole/state/project.js | 2 +- .../lib/writers/interactiveConsole/state/server.js | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index cb655a0329b..3c586eb794d 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -5,15 +5,11 @@ import chalk from "chalk"; import Logger from "../loggers/Logger.js"; import {getLevelPrefix} from "./internal/levelPrefix.js"; import {createHeaderState, setTool} from "./interactiveConsole/state/header.js"; -import { - createProjectState, setProject, enablePlaceholders as enableProjectPlaceholders, -} from "./interactiveConsole/state/project.js"; -import { - createServerState, setListening, enablePlaceholders as enableServerPlaceholders, -} from "./interactiveConsole/state/server.js"; +import {createProjectState, setProject, enableProjectPlaceholders} from "./interactiveConsole/state/project.js"; +import {createServerState, setListening, enableServerPlaceholders} from "./interactiveConsole/state/server.js"; import { createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, STATES, - enablePlaceholders as enableBuildPlaceholders, + enableBuildPlaceholders, } from "./interactiveConsole/state/build.js"; import { renderHeaderRegion, renderProjectRegion, renderServerRegion, renderBuildRegion, diff --git a/packages/logger/lib/writers/interactiveConsole/state/build.js b/packages/logger/lib/writers/interactiveConsole/state/build.js index 906376dbefa..e62fd6f4aad 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/build.js +++ b/packages/logger/lib/writers/interactiveConsole/state/build.js @@ -87,7 +87,7 @@ export function setError(state, message) { // Advance the region into a "starting" placeholder state so the Status row is // visible from the first frame. Called from `ui5.tool-mode`. Real state // transitions (READY/BUILDING/…) replace it. -export function enablePlaceholders(state) { +export function enableBuildPlaceholders(state) { if (state.state === STATES.INITIAL) { state.state = STATES.STARTING; } diff --git a/packages/logger/lib/writers/interactiveConsole/state/project.js b/packages/logger/lib/writers/interactiveConsole/state/project.js index 9cde3067be9..f92a81622fa 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/project.js +++ b/packages/logger/lib/writers/interactiveConsole/state/project.js @@ -15,6 +15,6 @@ export function setProject(state, evt) { state.framework = evt.framework ? {name: evt.framework.name, version: evt.framework.version} : null; } -export function enablePlaceholders(state) { +export function enableProjectPlaceholders(state) { state.showPlaceholders = true; } diff --git a/packages/logger/lib/writers/interactiveConsole/state/server.js b/packages/logger/lib/writers/interactiveConsole/state/server.js index bf1dc9b6876..1f678ebb977 100644 --- a/packages/logger/lib/writers/interactiveConsole/state/server.js +++ b/packages/logger/lib/writers/interactiveConsole/state/server.js @@ -15,7 +15,7 @@ export function setListening(state, evt) { state.acceptRemoteConnections = !!evt.acceptRemoteConnections; } -export function enablePlaceholders(state, {acceptRemoteConnections} = {}) { +export function enableServerPlaceholders(state, {acceptRemoteConnections} = {}) { state.showPlaceholders = true; if (typeof acceptRemoteConnections === "boolean") { state.acceptRemoteConnections = acceptRemoteConnections; From fe85d4084a5dfc65e884916ba643a9e2ec1dd598 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 13:43:36 +0200 Subject: [PATCH 14/16] test(logger): Restore banner-move test coverage to 100% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds the tests lost when the ui5 serve banner moved from packages/cli to packages/logger. Coverage across statements, branches, functions, and lines is back at 100%. - Console.js: project-resolved + server-listening handlers (with and without acceptRemoteConnections, non-array urls, Local fallback). - InteractiveConsole.js: spinner tick, resize, static init/stop, all serve-status variants, log-level filtering, frame-growth path, timer without unref support, and the full process.stdout/stderr interception suite (chunk joining, partial-flush on stop, Buffer/Uint8Array chunks, async callback contract, stopped-writer short-circuit). - interactiveConsole/render.js: region renderers + status-line glyph- and color-per-state matrices. - interactiveConsole/format.js: COLORFGBG dark-mode detection via cache-busting dynamic import. - interactiveConsole/state/*: unit tests for the four state modules. Also marks two defensive #stopped guards in InteractiveConsole as istanbul-ignore — they cover the case where a listener or timer fires after disable(), which cannot happen because disable() detaches all listeners and clears the tick timer synchronously. --- .../logger/lib/writers/InteractiveConsole.js | 6 + packages/logger/test/lib/writers/Console.js | 114 ++++ .../test/lib/writers/InteractiveConsole.js | 495 ++++++++++++++++++ .../lib/writers/interactiveConsole/format.js | 84 +++ .../lib/writers/interactiveConsole/render.js | 383 ++++++++++++++ .../writers/interactiveConsole/state/build.js | 121 +++++ .../interactiveConsole/state/header.js | 24 + .../interactiveConsole/state/project.js | 37 ++ .../interactiveConsole/state/server.js | 63 +++ 9 files changed, 1327 insertions(+) create mode 100644 packages/logger/test/lib/writers/interactiveConsole/format.js create mode 100644 packages/logger/test/lib/writers/interactiveConsole/render.js create mode 100644 packages/logger/test/lib/writers/interactiveConsole/state/build.js create mode 100644 packages/logger/test/lib/writers/interactiveConsole/state/header.js create mode 100644 packages/logger/test/lib/writers/interactiveConsole/state/project.js create mode 100644 packages/logger/test/lib/writers/interactiveConsole/state/server.js diff --git a/packages/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js index 3c586eb794d..5294c43eec6 100644 --- a/packages/logger/lib/writers/InteractiveConsole.js +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -175,6 +175,9 @@ class InteractiveConsole { } #render() { + /* istanbul ignore if — defensive: #render is only called from event + handlers (detached on disable) and the tick timer (cleared on + disable), so this branch is unreachable in practice. */ if (this.#stopped) { return; } @@ -412,6 +415,9 @@ class InteractiveConsole { } #scheduleTick() { + /* istanbul ignore if — defensive: #scheduleTick is only reached from + event handlers (detached on disable), so #stopped is always false + here in practice. */ if (this.#stopped) { return; } diff --git a/packages/logger/test/lib/writers/Console.js b/packages/logger/test/lib/writers/Console.js index 4efab6edd7c..18715214744 100644 --- a/packages/logger/test/lib/writers/Console.js +++ b/packages/logger/test/lib/writers/Console.js @@ -1119,3 +1119,117 @@ test.serial("Build metadata events (same project)", (t) => { `info Project 1 of 1: ${figures.tick} Finished building project-type project project.a\n`, "Logged expected message"); }); + +test.serial("Project resolved event renders a one-line summary", (t) => { + const {stderrWriteStub} = t.context; + process.emit("ui5.project-resolved", { + name: "my.app", + type: "application", + version: "1.0.0", + }); + t.is(stderrWriteStub.callCount, 1); + t.is(stripAnsi(stderrWriteStub.getCall(0).args[0]), + `info Root project: application project my.app (1.0.0)\n`); +}); + +test.serial("Project resolved event omits the type label when the type is missing", (t) => { + const {stderrWriteStub} = t.context; + process.emit("ui5.project-resolved", {name: "my.app", version: "1.0.0"}); + t.is(stderrWriteStub.callCount, 1); + t.is(stripAnsi(stderrWriteStub.getCall(0).args[0]), + `info Root project: my.app (1.0.0)\n`); +}); + +test.serial("Project resolved event omits the version suffix when the version is missing", (t) => { + const {stderrWriteStub} = t.context; + process.emit("ui5.project-resolved", {name: "my.app", type: "library"}); + t.is(stderrWriteStub.callCount, 1); + t.is(stripAnsi(stderrWriteStub.getCall(0).args[0]), + `info Root project: library project my.app\n`); +}); + +test.serial("Server listening event: writes 'Server started' + URL to stdout", (t) => { + const stdoutWriteStub = sinon.stub(process.stdout, "write"); + const {stderrWriteStub} = t.context; + + process.emit("ui5.server-listening", { + urls: [ + {label: "Local", url: "http://localhost:8080"}, + {label: "Network", url: "http://10.0.0.1:8080"}, + ], + acceptRemoteConnections: false, + }); + + // Only the local URL leaks into the CLI's parseable output — network + // addresses are the interactive writer's job. + t.is(stdoutWriteStub.callCount, 1); + t.is(stdoutWriteStub.getCall(0).args[0], `Server started\nURL: http://localhost:8080\n`); + // No stderr writes when acceptRemoteConnections=false. + t.is(stderrWriteStub.callCount, 0); +}); + +test.serial("Server listening event: prints the accept-remote-connections warning on stderr", (t) => { + const stdoutWriteStub = sinon.stub(process.stdout, "write"); + const {stderrWriteStub} = t.context; + + process.emit("ui5.server-listening", { + urls: [{label: "Local", url: "http://localhost:8080"}], + acceptRemoteConnections: true, + }); + + t.is(stdoutWriteStub.callCount, 1, "Local URL is still announced on stdout"); + t.is(stdoutWriteStub.getCall(0).args[0], `Server started\nURL: http://localhost:8080\n`); + + // Warning surrounds itself with blank lines and prints line by line — enough + // writes to see all of them. Match the assembled body against the well-known + // header so a swap of the warning source is caught. + t.true(stderrWriteStub.callCount >= 3, "warning is emitted on stderr"); + const stderrBody = stripAnsi( + stderrWriteStub.getCalls().map((c) => c.args[0]).join("")); + t.regex(stderrBody, /accepting connections from all hosts/); +}); + +test.serial("Server listening event: falls back to the first URL entry when 'Local' is absent", (t) => { + const stdoutWriteStub = sinon.stub(process.stdout, "write"); + + process.emit("ui5.server-listening", { + urls: [{label: "Network", url: "http://10.0.0.1:8080"}], + acceptRemoteConnections: false, + }); + + t.is(stdoutWriteStub.callCount, 1); + t.is(stdoutWriteStub.getCall(0).args[0], `Server started\nURL: http://10.0.0.1:8080\n`); +}); + +test.serial("Server listening event: no output when the URL list is empty", (t) => { + const stdoutWriteStub = sinon.stub(process.stdout, "write"); + + process.emit("ui5.server-listening", {urls: [], acceptRemoteConnections: false}); + t.is(stdoutWriteStub.callCount, 0, "no 'Server started' line when there is no URL to announce"); +}); + +test.serial("Server listening event: no output when urls is not an array", (t) => { + const stdoutWriteStub = sinon.stub(process.stdout, "write"); + + // Guards the `Array.isArray(urls)` check — a broken caller shouldn't crash + // the writer or emit half a message. + process.emit("ui5.server-listening", {urls: undefined, acceptRemoteConnections: false}); + t.is(stdoutWriteStub.callCount, 0); +}); + +test.serial("Server listening event: no output when the first URL entry has no url property", (t) => { + const stdoutWriteStub = sinon.stub(process.stdout, "write"); + + // Only the second entry has a URL, but the first (a `Local` without a URL) + // wins the label match and the fallback isn't invoked. This matches the + // production contract — a broken payload doesn't get partially recovered. + process.emit("ui5.server-listening", { + urls: [ + {label: "Local"}, + {label: "Network", url: "http://10.0.0.1:8080"}, + ], + acceptRemoteConnections: false, + }); + t.is(stdoutWriteStub.callCount, 0, + "no output when the selected entry has no url"); +}); diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index 9d2c104bf8b..33ff8f4e8bb 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -399,3 +399,498 @@ test.serial("region blocks are separated by a blank line in the composed frame", // Blank line between server block and status t.regex(frame, /Network:.*\n\nStatus/); }); + +test.serial("static init() returns an enabled writer", (t) => { + const stopHandler = sinon.stub(); + process.on("ui5.log.stop-console", stopHandler); + // init() constructs against the real process.stderr, which would spew ANSI + // escapes into the AVA runner. Stub the underlying writes for the duration + // of the test. + const origStdout = process.stdout.write; + const origStderr = process.stderr.write; + process.stdout.write = () => true; + process.stderr.write = () => true; + t.teardown(() => { + process.stdout.write = origStdout; + process.stderr.write = origStderr; + }); + + const writer = InteractiveConsole.init(); + t.true(stopHandler.callCount >= 1, "init() displaces any prior writer"); + process.off("ui5.log.stop-console", stopHandler); + writer.disable(); +}); + +test.serial("static stop() emits ui5.log.stop-console", (t) => { + const stopHandler = sinon.stub(); + process.on("ui5.log.stop-console", stopHandler); + InteractiveConsole.stop(); + t.is(stopHandler.callCount, 1, "stop() emits the shutdown event once"); + process.off("ui5.log.stop-console", stopHandler); +}); + +test.serial("disable() is idempotent", (t) => { + const {writer} = createWriter(); + writer.disable(); + t.notThrows(() => writer.disable(), "second disable() is a no-op"); +}); + +test.serial("disable() flushes a trailing newline", (t) => { + const {writer, stderr} = createWriter(); + stderr.writes.length = 0; + writer.disable(); + // log-update.done() clears its own state on stop; the writer itself emits + // a trailing newline so the next prompt lands on a fresh row below the + // final frame. + const trailing = stderr.writes.join(""); + t.true(trailing.endsWith("\n"), "trailing newline written on stop"); +}); + +test.serial("logAbove: after disable() writes directly to stderr", (t) => { + const {writer, stderr} = createWriter(); + writer.disable(); + stderr.writes.length = 0; + writer.logAbove("trailing warn"); + const after = stderr.writes.join(""); + t.regex(after, /trailing warn/, "line is written verbatim"); + t.true(after.endsWith("\n"), "newline appended"); +}); + +test.serial("resize handler re-renders the status line", (t) => { + const {writer, stderr} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-ready"}); + stderr.writes.length = 0; + + stderr.columns = 40; + stderr.emit("resize"); + + const after = stripAnsi(stderr.writes.join("")); + t.regex(after, /Status/, "resize triggers a status line re-render"); + writer.disable(); +}); + +test.serial("writer survives a stderr without on/off methods", (t) => { + // A minimal write-only sink (no event emitter API) must not crash the + // writer's resize hook setup — the live region is best-effort about + // terminal capabilities. + const stderr = { + columns: 80, + writes: [], + write(chunk) { + this.writes.push(chunk); + return true; + }, + }; + const writer = new InteractiveConsole({stderr}); + writer.enable(); + t.notThrows(() => writer.disable(), "disable() handles stderr without off()"); +}); + +test.serial("serve-status: same state re-renders without resetting the tick timer", (t) => { + // Drive the writer into READY, then emit another serve-ready to hit the + // same-state branch in #transitionTo that re-renders without resetting + // the tick timer. + const {writer} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-ready"}); + t.notThrows(() => process.emit("ui5.serve-status", {status: "serve-ready"})); + t.is(writer._getStateForTest().build.state, STATES.READY); + writer.disable(); +}); + +test.serial("serve-status: serve-build-done sets lastBuildHrtime + returns to READY", (t) => { + const {writer} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-building"}); + t.is(writer._getStateForTest().build.state, STATES.BUILDING); + process.emit("ui5.serve-status", {status: "serve-build-done", hrtime: [1, 500_000_000]}); + const state = writer._getStateForTest().build; + t.is(state.state, STATES.READY); + t.deepEqual(state.lastBuildHrtime, [1, 500_000_000]); + writer.disable(); +}); + +test.serial("serve-status: serve-build-done without a valid hrtime records null", (t) => { + const {writer} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-build-done"}); + t.is(writer._getStateForTest().build.lastBuildHrtime, null); + writer.disable(); +}); + +test.serial("serve-status: serve-stale records changedProjects and transitions to STALE", (t) => { + const {writer} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-stale", changedProjects: ["my.app"]}); + const state = writer._getStateForTest().build; + t.is(state.state, STATES.STALE); + t.deepEqual(state.changedProjects, ["my.app"]); + writer.disable(); +}); + +test.serial("serve-status: serve-stale without a payload falls back to an empty list", (t) => { + const {writer} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-stale"}); + t.deepEqual(writer._getStateForTest().build.changedProjects, []); + writer.disable(); +}); + +test.serial("serve-status: serve-error coerces a non-Error payload to a string", (t) => { + const {writer} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-error", error: "plain string failure"}); + t.is(writer._getStateForTest().build.errorMessage, "plain string failure"); + writer.disable(); +}); + +test.serial("serve-status: serve-error does not echo the error via logAbove", (t) => { + const {writer, stderr} = createWriter(); + const err = new Error("boom"); + err.stack = "Error: boom\n at "; + process.emit("ui5.serve-status", {status: "serve-error", error: err}); + // The writer intentionally does NOT echo the error via logAbove — + // BuildServer's own log.error and the yargs fail-handler already render + // the message and stack trace. + const output = stripAnsi(stderr.writes.join("")); + t.notRegex(output, /at /, "stack trace is not duplicated via logAbove"); + writer.disable(); +}); + +test.serial("log events without moduleName render without a module prefix", (t) => { + const {writer, stderr} = createWriter(); + stderr.writes.length = 0; + process.emit("ui5.log", {level: "warn", message: "module-less warning"}); + const output = stripAnsi(stderr.writes.join("")); + t.regex(output, /warn module-less warning/, + "warn line renders with just the level prefix and message"); + writer.disable(); +}); + +test.serial("log events are suppressed when Logger level filters them out", async (t) => { + const {default: Logger} = await import("../../../lib/loggers/Logger.js"); + const previousLevel = Logger.getLevel(); + Logger.setLevel("error"); + t.teardown(() => Logger.setLevel(previousLevel)); + + const {writer, stderr} = createWriter(); + stderr.writes.length = 0; + + process.emit("ui5.log", {level: "warn", message: "Filtered warning"}); + const afterWarn = stripAnsi(stderr.writes.join("")); + t.notRegex(afterWarn, /Filtered warning/, "warn is suppressed when log level is error"); + + process.emit("ui5.log", {level: "error", message: "Real failure"}); + const afterError = stripAnsi(stderr.writes.join("")); + t.regex(afterError, /Real failure/, "error still passes through"); + + writer.disable(); +}); + +test.serial("build-status: project-build-skip also advances the counter", (t) => { + const {writer} = createWriter(); + process.emit("ui5.build-metadata", {projectsToBuild: ["a", "b"]}); + process.emit("ui5.build-status", {projectName: "b", status: "project-build-skip"}); + const state = writer._getStateForTest().build; + t.is(state.currentProjectIndex, 2); + t.is(state.currentProjectName, "b"); + writer.disable(); +}); + +test.serial("build-status: unknown statuses are ignored", (t) => { + const {writer} = createWriter(); + process.emit("ui5.build-metadata", {projectsToBuild: ["a", "b"]}); + const beforeIndex = writer._getStateForTest().build.currentProjectIndex; + // Statuses outside the known set must not advance the project counter + // — the writer's project tally is driven solely by build-start/skip. + process.emit("ui5.build-status", {projectName: "a", status: "project-build-end"}); + t.is(writer._getStateForTest().build.currentProjectIndex, beforeIndex, + "unknown status leaves the counter untouched"); + writer.disable(); +}); + +test.serial("project-build-status: non task-start events are ignored", (t) => { + const {writer} = createWriter(); + // task-end (and any other non-start status) must not overwrite the + // currently-displayed task name — the live region keeps the most recent + // in-progress task visible until the next task-start. + process.emit("ui5.project-build-status", {taskName: "minify", status: "task-start"}); + process.emit("ui5.project-build-status", {taskName: "css", status: "task-end"}); + t.is(writer._getStateForTest().build.currentTaskName, "minify", + "task-end did not overwrite the active task name"); + writer.disable(); +}); + +test.serial("spinner tick advances spinFrame while BUILDING", (t) => { + const clock = sinon.useFakeTimers(); + t.teardown(() => clock.restore()); + + const {writer, stderr} = createWriter(); + process.emit("ui5.serve-status", {status: "serve-building"}); + const frameBefore = writer._getStateForTest().build.spinFrame; + stderr.writes.length = 0; + clock.tick(150); + const frameAfter = writer._getStateForTest().build.spinFrame; + t.true(frameAfter > frameBefore, "spinFrame advanced on tick"); + t.true(stderr.writes.length > 0, "tick triggered a re-render"); + writer.disable(); +}); + +test.serial("frame growth forces log-update off the diff path", (t) => { + // The InteractiveConsole works around a `log-update` bug where a growing + // frame miscomputes the erase range and eats scrollback above the live + // region. Whenever the frame's row count changes, the writer calls + // `logUpdate.clear()` to force the first-frame code path. + const {writer, stderr} = createWriter(); + // First frame: header only. + process.emit("ui5.tool-info", {name: "UI5 CLI", version: "1.2.3"}); + const framesAfterHeader = stderr.writes.length; + // Second frame: header + project. Different row count. + process.emit("ui5.project-resolved", { + name: "my.app", type: "application", version: "1.0.0", framework: null, + }); + t.true(stderr.writes.length > framesAfterHeader, + "project-resolved triggers additional writes when the frame grows"); + writer.disable(); +}); + +// ---- process.stdout / process.stderr interception --------------------------- +// InteractiveConsole replaces process.stdout.write / process.stderr.write while +// active so writes that bypass @ui5/logger (custom tasks, third-party libs) get +// routed through logAbove() instead of corrupting the live region. These tests +// exercise that path against the real process streams — with the real writes +// stubbed out to keep the test runner's stdout quiet. + +function stubProcessStreams(t) { + const stdoutWrites = []; + const stderrWrites = []; + const trueOrigStdout = process.stdout.write; + const trueOrigStderr = process.stderr.write; + process.stdout.write = (chunk) => { + stdoutWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }; + process.stderr.write = (chunk) => { + stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }; + // `origStdout`/`origStderr` reference the STUBS — that's what the writer + // captured on install, and therefore what should be restored on stop. + const origStdout = process.stdout.write; + const origStderr = process.stderr.write; + t.teardown(() => { + process.stdout.write = trueOrigStdout; + process.stderr.write = trueOrigStderr; + }); + return {stdoutWrites, stderrWrites, origStdout, origStderr}; +} + +test.serial("intercepts direct process.stderr.write and routes lines above the live region", (t) => { + const {stderrWrites, origStderr} = stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + // The intercepted stream must NOT be the raw original — the wrapper the + // writer installed sits on top. + t.not(process.stderr.write, origStderr, "process.stderr.write is wrapped"); + + process.stderr.write("boom\n"); + + const output = stripAnsi(stderrWrites.join("")); + t.regex(output, /boom/, "line written directly to stderr surfaces via logAbove"); + writer.disable(); + t.is(process.stderr.write, origStderr, "process.stderr.write is restored on disable"); +}); + +test.serial("intercepts direct process.stdout.write too", (t) => { + const {stderrWrites, origStdout} = stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + t.not(process.stdout.write, origStdout, "process.stdout.write is wrapped"); + + // A custom task that writes to stdout is just as bad for the live region + // as one writing to stderr — same TTY, same cursor. + process.stdout.write("stdout line\n"); + + const output = stripAnsi(stderrWrites.join("")); + t.regex(output, /stdout line/, "line written directly to stdout surfaces via logAbove"); + writer.disable(); + t.is(process.stdout.write, origStdout, "process.stdout.write is restored on disable"); +}); + +test.serial("joins split-chunk writes into a single logged line", (t) => { + const {stderrWrites} = stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + stderrWrites.length = 0; + + // A single logical "foobar" line delivered as two chunks — a naive + // per-chunk logAbove call would emit two separate lines and desync the + // live region. The writer must buffer until the newline arrives. + process.stderr.write("foo"); + process.stderr.write("bar\n"); + + const output = stripAnsi(stderrWrites.join("")); + t.regex(output, /foobar/, "split chunks are joined at the newline"); + writer.disable(); +}); + +test.serial("disable() flushes a buffered partial line", (t) => { + const {stderrWrites} = stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + stderrWrites.length = 0; + + // No trailing newline — the fragment stays buffered until disable() + // flushes it. Without the flush the message would be lost entirely. + process.stderr.write("dangling fragment"); + writer.disable(); + + const output = stripAnsi(stderrWrites.join("")); + t.regex(output, /dangling fragment/, "partial line flushed on disable"); +}); + +test.serial("interceptor invokes the write() callback asynchronously", async (t) => { + stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + + // stream.write(chunk, callback) contract: callback fires once the write + // drains. Our sink is synchronous, but the callback must still be async + // so the caller doesn't observe recursion inside its own write() call. + const observedDuringCall = await new Promise((resolve) => { + let calledSync = false; + process.stderr.write("cb line\n", () => { + resolve(calledSync); + }); + calledSync = true; + }); + t.true(observedDuringCall, "callback fires after the write() call returns"); + writer.disable(); +}); + +test.serial("interceptor handles Buffer chunks with an explicit encoding", (t) => { + const {stderrWrites} = stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + stderrWrites.length = 0; + + process.stderr.write(Buffer.from("buffered line\n", "utf8"), "utf8"); + + const output = stripAnsi(stderrWrites.join("")); + t.regex(output, /buffered line/, "buffer chunk decoded and surfaced"); + writer.disable(); +}); + +test.serial("interceptor handles Buffer chunks with an encoding + callback", (t) => { + const {stderrWrites} = stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + stderrWrites.length = 0; + + // Exercises the encoding argument branch AND the callback argument branch + // of write(chunk, encoding, callback) at the same time. + return new Promise((resolve) => { + process.stderr.write(Buffer.from("cb+enc\n", "utf8"), "utf8", () => { + const output = stripAnsi(stderrWrites.join("")); + t.regex(output, /cb\+enc/); + writer.disable(); + resolve(); + }); + }); +}); + +test.serial("interceptor handles Uint8Array chunks that are not Buffer instances", (t) => { + const {stderrWrites} = stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + stderrWrites.length = 0; + + // A plain Uint8Array trips the final fallback branch — neither a string + // nor a Buffer — via `Buffer.from(chunk).toString(encoding)`. + const arr = new Uint8Array([104, 105, 10]); // "hi\n" + process.stderr.write(arr); + + const output = stripAnsi(stderrWrites.join("")); + t.regex(output, /hi/, "Uint8Array chunk decoded and surfaced"); + writer.disable(); +}); + +test.serial("does not intercept when a non-process stderr is supplied", (t) => { + // The interceptor is gated on stderr === process.stderr — the vast + // majority of unit tests pass a stub stderr and must not have process.* + // yanked out from under them. + const {origStderr, origStdout} = stubProcessStreams(t); + const {writer} = createWriter(); + t.is(process.stderr.write, origStderr, + "stderr.write is untouched when a stub stderr is used"); + t.is(process.stdout.write, origStdout, + "stdout.write is untouched when a stub stderr is used"); + writer.disable(); +}); + +test.serial("stopped writer forwards intercepted writes to the original stream", (t) => { + const {stderrWrites, origStderr} = stubProcessStreams(t); + const writer = new InteractiveConsole(); + writer.enable(); + const wrapper = process.stderr.write; + // Manually flip the writer into #stopped without going through disable(), + // which would restore process.stderr. That leaves the wrapper installed — + // exactly the state where the "stopped" branch inside the wrapper must + // short-circuit straight to the original stub. + writer.disable(); + // Put the wrapper back in place so we can drive it while #stopped is true. + process.stderr.write = wrapper; + stderrWrites.length = 0; + process.stderr.write("late byte\n"); + const output = stderrWrites.join(""); + t.true(output.includes("late byte\n"), + "intercepted write short-circuits to the underlying stream when stopped"); + // Restore the real (stubbed) write before teardown. + process.stderr.write = origStderr; +}); + +test.serial("enable() reuses an existing log-update instance across disable+enable", (t) => { + // disable() doesn't destroy log-update — a re-enable simply continues to + // write against the same instance. This test covers the `#logUpdate` truthy + // branch of the initializer. + const stderr = createStubStderr(); + const writer = new InteractiveConsole({stderr}); + writer.enable(); + writer.disable(); + t.notThrows(() => writer.enable(), "second enable() succeeds without re-initialising log-update"); + writer.disable(); +}); + +test.serial("spinner tick timer without unref support is left as-is", (t) => { + // Node returns Timeout objects with an unref() method; some polyfills and + // runtimes don't. The writer must survive that shape — it doesn't rely on + // unref for correctness, only for not blocking process exit. + const origSetInterval = globalThis.setInterval; + globalThis.setInterval = (fn, ms) => { + const timer = origSetInterval(fn, ms); + // Shadow the inherited `unref` with a non-function so the writer's + // `typeof … === "function"` guard takes the fallback path. + timer.unref = undefined; + return timer; + }; + t.teardown(() => { + globalThis.setInterval = origSetInterval; + }); + + const {writer} = createWriter(); + // Enter BUILDING to install the tick timer. + t.notThrows(() => process.emit("ui5.serve-status", {status: "serve-building"}), + "writer survives an unref-less timer"); + writer.disable(); +}); + +test.serial("ui5.log.stop-console handler calls disable() on the active writer", (t) => { + // When another writer emits ui5.log.stop-console to claim the terminal, the + // active writer's own listener must invoke disable() so it detaches all + // event listeners and restores process.stdout/stderr. + const {stderr} = createWriter(); + stderr.writes.length = 0; + + process.emit("ui5.log.stop-console"); + + // A disabled writer no longer reacts to events — verify by emitting one + // that would normally scroll a line above the frame. + stderr.writes.length = 0; + process.emit("ui5.log", {level: "warn", message: "post-stop warn"}); + const output = stripAnsi(stderr.writes.join("")); + t.notRegex(output, /post-stop warn/, "listeners are detached after the stop-console handler"); +}); diff --git a/packages/logger/test/lib/writers/interactiveConsole/format.js b/packages/logger/test/lib/writers/interactiveConsole/format.js new file mode 100644 index 00000000000..097f2b60184 --- /dev/null +++ b/packages/logger/test/lib/writers/interactiveConsole/format.js @@ -0,0 +1,84 @@ +import test from "ava"; +import chalk from "chalk"; + +// AVA workers are non-TTY, so chalk auto-detects "no color". Force truecolor +// so the palette wrappers emit their SGR codes and the palette assertions +// below have something to match against. +chalk.level = 3; + +// `format.js` snapshots DARK_MODE at import time by reading COLORFGBG. To +// exercise both palettes we set the env var, re-import via a cache-busting +// query, and inspect the exported theme primitives. Every case restores the +// env so tests remain order-independent. + +async function loadFormat(colorfgbg) { + const previous = process.env.COLORFGBG; + if (colorfgbg === undefined) { + delete process.env.COLORFGBG; + } else { + process.env.COLORFGBG = colorfgbg; + } + try { + // A unique query param bypasses Node's ESM module cache so the module + // runs its top-level DARK_MODE detection against the current env. + const suffix = Math.random().toString(36).slice(2); + return await import( + `../../../../lib/writers/interactiveConsole/format.js?bg=${suffix}`); + } finally { + if (previous === undefined) { + delete process.env.COLORFGBG; + } else { + process.env.COLORFGBG = previous; + } + } +} + +// Terminal SGR sequences carry the decimal RGB triple; look for that rather +// than the source hex so we don't depend on chalk's exact serialisation. +const LIGHT_BRAND = /24;115;180/; +const LIGHT_ACCENT = /83;184;222/; +const DARK_BRAND = /255;90;55/; +const DARK_ACCENT = /255;164;44/; + +test.serial("format: brand and accent hexes match the light palette when COLORFGBG is unset", async (t) => { + const {brand, accent} = await loadFormat(undefined); + t.regex(brand("hello"), LIGHT_BRAND, "light-mode brand hex is applied"); + t.regex(accent("hello"), LIGHT_ACCENT, "light-mode accent hex is applied"); +}); + +test.serial("format: dark palette activates when COLORFGBG signals a dark background (bg < 7)", async (t) => { + const {brand, accent} = await loadFormat("15;0"); + t.regex(brand("hello"), DARK_BRAND, "dark-mode brand hex is applied when bg index is 0"); + t.regex(accent("hello"), DARK_ACCENT, "dark-mode accent hex is applied"); +}); + +test.serial("format: dark palette activates for the special bg=8 index", async (t) => { + // Index 8 is conventionally a "bright black" / dark grey background that + // several terminals report. It's grouped with the < 7 range explicitly. + const {brand} = await loadFormat("15;8"); + t.regex(brand("hello"), DARK_BRAND, "bg=8 is treated as dark"); +}); + +test.serial("format: light palette when COLORFGBG bg index is >= 7 and not 8", async (t) => { + const {brand} = await loadFormat("0;15"); + t.regex(brand("hello"), LIGHT_BRAND, "bg=15 is treated as light"); +}); + +test.serial("format: light palette when COLORFGBG has a non-numeric background", async (t) => { + // A malformed COLORFGBG (e.g. only a foreground index, or garbage) should + // not throw — the parseInt returns NaN and we fall back to light. + const {brand} = await loadFormat("nope"); + t.regex(brand("hello"), LIGHT_BRAND, "NaN parsed bg falls back to light"); +}); + +test.serial("format: exports a bold accent variant and an arrow glyph", async (t) => { + // Sanity — these primitives feed the region renderers and their contract + // (bold/hex on accentBold, non-empty arrow) shouldn't drift silently. + const {accentBold, arrow, placeholder} = await loadFormat(undefined); + // accentBold wraps in bold + accent hex. + t.regex(accentBold("hi"), /1m/, "accentBold includes the bold SGR (1m)"); + t.true(typeof arrow === "string" && arrow.length > 0, "arrow glyph is a non-empty string"); + // placeholder uses dim + italic. + t.regex(placeholder("x"), /2m/, "placeholder uses the dim SGR (2m)"); + t.regex(placeholder("x"), /3m/, "placeholder uses the italic SGR (3m)"); +}); diff --git a/packages/logger/test/lib/writers/interactiveConsole/render.js b/packages/logger/test/lib/writers/interactiveConsole/render.js new file mode 100644 index 00000000000..9d2c86dbd49 --- /dev/null +++ b/packages/logger/test/lib/writers/interactiveConsole/render.js @@ -0,0 +1,383 @@ +import test from "ava"; +import stripAnsi from "strip-ansi"; +import chalk from "chalk"; +import figures from "figures"; + +import { + renderHeaderRegion, + renderProjectRegion, + renderServerRegion, + renderBuildRegion, +} from "../../../../lib/writers/interactiveConsole/render.js"; +import {REMOTE_CONNECTIONS_WARNING_LINES} from + "../../../../lib/writers/interactiveConsole/remoteConnectionsWarning.js"; +import { + createBuildState, + transitionTo, + setError, + setTask, + beginBuild, + advanceToProject, + STATES, +} from "../../../../lib/writers/interactiveConsole/state/build.js"; +import {createHeaderState, setTool} from + "../../../../lib/writers/interactiveConsole/state/header.js"; +import {createProjectState, setProject, enableProjectPlaceholders} from + "../../../../lib/writers/interactiveConsole/state/project.js"; +import {createServerState, setListening, enableServerPlaceholders} from + "../../../../lib/writers/interactiveConsole/state/server.js"; + +// chalk auto-detects "no color" in non-TTY subprocesses (e.g. the AVA worker), which would strip +// every color code from the status line and defeat the color-per-state assertions below. Force +// truecolor for this file. All other tests here run through stripAnsi and tolerate either mode. +chalk.level = 3; + +// ---- renderHeaderRegion ------------------------------------------------------- + +test("renderHeaderRegion: empty when no tool is set", (t) => { + t.deepEqual(renderHeaderRegion(createHeaderState()), []); +}); + +test("renderHeaderRegion: includes brand name and dim version prefix", (t) => { + const state = createHeaderState(); + setTool(state, {name: "UI5 CLI", version: "1.2.3"}); + const plain = renderHeaderRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /UI5 CLI v1\.2\.3/); +}); + +test("renderHeaderRegion: falls back to default brand name when tool.name is missing", (t) => { + const state = createHeaderState(); + setTool(state, {name: undefined, version: "1.0.0"}); + const plain = renderHeaderRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /UI5 CLI v1\.0\.0/, "renders default brand name when tool provides no name"); +}); + +test("renderHeaderRegion: omits version when tool.version is missing", (t) => { + const state = createHeaderState(); + setTool(state, {name: "UI5 CLI"}); + const plain = renderHeaderRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /UI5 CLI/); + t.notRegex(plain, /UI5 CLI v/, "no version marker when tool has no version"); +}); + +// ---- renderProjectRegion ------------------------------------------------------ + +test("renderProjectRegion: empty when no project and no placeholders", (t) => { + t.deepEqual(renderProjectRegion(createProjectState()), []); +}); + +test("renderProjectRegion: renders placeholders when enabled", (t) => { + const state = createProjectState(); + enableProjectPlaceholders(state); + const plain = renderProjectRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Project\s+resolving…/); + t.regex(plain, /Framework\s+resolving…/); +}); + +test("renderProjectRegion: renders project, type, and version", (t) => { + const state = createProjectState(); + setProject(state, { + name: "my.app", + type: "application", + version: "1.0.0", + framework: {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/); +}); + +test("renderProjectRegion: renders framework without a version when only the name is known", (t) => { + const state = createProjectState(); + setProject(state, { + name: "my.app", + type: "application", + version: "1.0.0", + framework: {name: "OpenUI5"}, + }); + const plain = renderProjectRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Framework\s+OpenUI5$/m); +}); + +test("renderProjectRegion: shows a dim '(none)' framework row when project has no framework", (t) => { + // The Framework row is always rendered so the live region's height stays + // constant once the project resolves — otherwise the status line would + // shift down when a framework appears between frames. + const state = createProjectState(); + setProject(state, { + name: "my.app", + type: "application", + version: "1.0.0", + framework: null, + }); + const plain = renderProjectRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Framework\s+\(none\)/); +}); + +test("renderProjectRegion: project rendered without type/version still includes the name", (t) => { + const state = createProjectState(); + setProject(state, {name: "bare.project"}); + const plain = renderProjectRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Project\s+bare\.project/); + t.notRegex(plain, /bare\.project\s+\(/, "no type marker is rendered when type is absent"); +}); + +// ---- renderServerRegion ------------------------------------------------------- + +test("renderServerRegion: empty when no urls and no placeholders", (t) => { + t.deepEqual(renderServerRegion(createServerState()), []); +}); + +test("renderServerRegion: renders Local + Network placeholders when acceptRemoteConnections=true", (t) => { + const state = createServerState(); + enableServerPlaceholders(state, {acceptRemoteConnections: true}); + const plain = renderServerRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Local:\s+binding…/); + t.regex(plain, /Network:\s+binding…/); + t.regex(plain, /accepting connections from all hosts/, + "remote-connections warning rendered up front"); +}); + +test("renderServerRegion: renders --accept-remote-connections hint by default", (t) => { + const state = createServerState(); + enableServerPlaceholders(state); + const plain = renderServerRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Local:\s+binding…/); + t.regex(plain, /Network:\s+use --accept-remote-connections to expose/); + t.false(plain.includes("accepting connections from all hosts"), + "warning block is omitted when remote connections are not accepted"); +}); + +test("renderServerRegion: shows single Local + hint when acceptRemoteConnections=false", (t) => { + const state = createServerState(); + setListening(state, { + urls: [{label: "Local", url: "http://localhost:8080"}], + acceptRemoteConnections: false, + }); + const plain = renderServerRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Local:\s+http:\/\/localhost:8080/); + t.regex(plain, /Network:\s+use --accept-remote-connections/); + t.false(plain.includes("accepting connections from all hosts")); +}); + +test("renderServerRegion: lists every Network URL when several addresses are supplied", (t) => { + const state = createServerState(); + setListening(state, { + urls: [ + {label: "Local", url: "http://localhost:8080"}, + {label: "Network", url: "http://10.0.0.1:8080"}, + {label: "Network", url: "http://10.0.0.2:8080"}, + {label: "Network", url: "http://10.0.0.3:8080"}, + ], + acceptRemoteConnections: true, + }); + const plain = renderServerRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Network:\s+http:\/\/10\.0\.0\.1:8080/); + // Continuation URLs are aligned under the first — they must be present but + // without a repeated "Network:" label. + t.regex(plain, /http:\/\/10\.0\.0\.2:8080/); + t.regex(plain, /http:\/\/10\.0\.0\.3:8080/); + const networkLabelHits = plain.match(/Network:/g) || []; + t.is(networkLabelHits.length, 1, "Network label appears only on the first row"); + // Warning block is present because acceptRemoteConnections=true. + for (const line of REMOTE_CONNECTIONS_WARNING_LINES) { + t.true(plain.includes(stripAnsi(line))); + } +}); + +test("renderServerRegion: renders labels other than Local/Network verbatim", (t) => { + // A future URL kind (e.g. WebSocket, HTTPS mirror) falls through to the + // "other" bucket and is rendered with its label preserved. + const state = createServerState(); + setListening(state, { + urls: [ + {label: "Local", url: "http://localhost:8080"}, + {label: "Custom", url: "wss://ws.localhost/socket"}, + ], + acceptRemoteConnections: false, + }); + const plain = renderServerRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Custom:\s+wss:\/\/ws\.localhost\/socket/); +}); + +test("renderServerRegion: non-array urls fall through to an empty list", (t) => { + // setListening() defensively coerces missing urls to [], so the region + // renders as "no local" (no Local row and no hint). + const state = createServerState(); + setListening(state, {urls: undefined, acceptRemoteConnections: false}); + const plain = renderServerRegion(state).map(stripAnsi).join("\n"); + t.notRegex(plain, /Local:/, "no Local row when the urls list is empty"); + t.notRegex(plain, /Network:/, "no Network hint when there is no Local row to hang it under"); +}); + +// ---- renderBuildRegion -------------------------------------------------------- + +test("renderBuildRegion: empty in INITIAL state", (t) => { + const state = createBuildState(); + t.deepEqual(renderBuildRegion(state), []); +}); + +test("renderBuildRegion: starting state renders a dim placeholder", (t) => { + const state = createBuildState(); + transitionTo(state, STATES.STARTING); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Status\s+\S+\s+starting/); +}); + +test("renderBuildRegion: ready state", (t) => { + const state = createBuildState(); + transitionTo(state, STATES.READY); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Status\s+.+?\s+ready/); +}); + +test("renderBuildRegion: ready state with hrtime shows elapsed suffix", (t) => { + const state = createBuildState(); + transitionTo(state, STATES.READY); + state.lastBuildHrtime = [0, 50_000_000]; + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /ready.*Time elapsed:/); +}); + +test("renderBuildRegion: stale state includes 'files changed' hint", (t) => { + const state = createBuildState(); + transitionTo(state, STATES.STALE); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Status\s+.+?\s+stale\s+·\s+files changed/); +}); + +test("renderBuildRegion: building state renders project counter + project + task", (t) => { + const state = createBuildState(); + beginBuild(state, ["proj-a", "my.app", "proj-c"]); + transitionTo(state, STATES.BUILDING); + advanceToProject(state, "my.app"); + setTask(state, "minify"); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /building/); + t.regex(plain, /2\/3 projects/); + t.regex(plain, /my\.app/); + t.regex(plain, /minify/); +}); + +test("renderBuildRegion: building state without counter or task falls back to bare label", (t) => { + // Fresh BUILDING transition with no build-metadata: no counter, no project, + // no task — just the state word. + const state = createBuildState(); + transitionTo(state, STATES.BUILDING); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Status\s+.+?\s+building/); + t.notRegex(plain, /projects/, "no counter fragment when totalProjects is 0"); +}); + +test("renderBuildRegion: building state pads project + task names for stable columns", (t) => { + const state = createBuildState(); + beginBuild(state, ["short", "longer-name"]); + transitionTo(state, STATES.BUILDING); + advanceToProject(state, "short"); + setTask(state, "task-with-a-longer-name"); + setTask(state, "css"); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + // projectNameWidth (11) pads "short" to "short "; the width never + // shrinks so the eventual re-render doesn't re-flow. + t.regex(plain, /short {6}/, "project name padded to widest known project"); + t.regex(plain, /css {20}/, "task name padded to widest known task"); +}); + +test("renderBuildRegion: error state shows message", (t) => { + const state = createBuildState(); + setError(state, "Build failed"); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /error\s+·\s+Build failed/); +}); + +test("renderBuildRegion: error state without message omits the separator", (t) => { + const state = createBuildState(); + setError(state, ""); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /error\s*$/m, "error state renders without a message tail"); +}); + +test("renderBuildRegion: unknown state falls back to a bare label", (t) => { + // Guards against future callers passing a state value the renderer + // doesn't recognise yet. + const state = createBuildState(); + state.state = "totally-new-state"; + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.regex(plain, /Status/); +}); + +test("renderBuildRegion: building spinner cycles through frames", (t) => { + const seen = new Set(); + for (let frame = 0; frame < 8; frame++) { + const state = createBuildState(); + transitionTo(state, STATES.BUILDING); + state.spinFrame = frame; + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + // The spinner glyph is the first non-space character after the Status label. + const match = plain.match(/Status\s+(\S)/); + t.truthy(match, "expected to find a spinner glyph"); + seen.add(match[1]); + } + t.true(seen.size > 1, "spinner glyph rotates across frames"); +}); + +test("renderBuildRegion: building state renders bare project/task names when no width has been recorded", (t) => { + // projectNameWidth / taskNameWidth are populated by beginBuild + setTask. + // If a project-build-start event arrives before build-metadata (unusual but + // possible in weird flows), the widths are still 0 and the renderer should + // emit the names verbatim instead of padEnd-ing to zero. + const state = createBuildState(); + transitionTo(state, STATES.BUILDING); + state.currentProjectName = "proj"; + state.currentTaskName = "css"; + // projectNameWidth / taskNameWidth left at 0 on purpose. + const [, line] = renderBuildRegion(state); + const plain = stripAnsi(line); + // Strict: exact substrings, no trailing padding after either name. + t.regex(plain, /· proj ·/); + t.regex(plain, /· css$/); +}); + +// Locks the glyph chosen per state. Complements the shape-only regex tests above: those verify +// "some glyph is followed by the state word"; these verify "it's specifically THIS glyph". A swap +// (e.g. bullet ↔ circle) would still look plausible but would break the design contract. +const GLYPH_BY_STATE = [ + {state: STATES.STARTING, glyph: figures.circle, name: "circle"}, + {state: STATES.READY, glyph: figures.bullet, name: "bullet"}, + {state: STATES.STALE, glyph: figures.circle, name: "circle"}, + {state: STATES.ERROR, glyph: figures.cross, name: "cross"}, +]; +for (const {state: s, glyph, name} of GLYPH_BY_STATE) { + test(`renderBuildRegion: ${s} uses the ${name} glyph`, (t) => { + const state = createBuildState(); + transitionTo(state, s); + const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); + t.true(plain.includes(glyph), + `expected output to contain ${JSON.stringify(glyph)}, got ${JSON.stringify(plain)}`); + }); +} + +// Locks the color chosen per state. Color is the primary semantic signal to a human reader +// (green=healthy, yellow=attention, red=error) and stripAnsi throws it away — so the plain-text +// tests above would happily accept a swap. Build the expected wrapped-word substring with the +// same chalk instance the renderer uses; matching it in the output proves the state word is +// wrapped in exactly that color span. +const WIDTH = "building".length; +const COLOR_BY_STATE = [ + {state: STATES.STARTING, wrap: (x) => chalk.dim(x), word: "starting", name: "dim"}, + {state: STATES.READY, wrap: (x) => chalk.green(x), word: "ready", name: "green"}, + {state: STATES.STALE, wrap: (x) => chalk.yellow(x), word: "stale", name: "yellow"}, + {state: STATES.BUILDING, wrap: (x) => chalk.yellow(x), word: "building", name: "yellow"}, + {state: STATES.ERROR, wrap: (x) => chalk.red(x), word: "error", name: "red"}, +]; +for (const {state: s, wrap, word, name} of COLOR_BY_STATE) { + test(`renderBuildRegion: ${s} state is rendered in ${name}`, (t) => { + const state = createBuildState(); + transitionTo(state, s); + const [, line] = renderBuildRegion(state); + const expected = wrap(word.padEnd(WIDTH)); + t.true(line.includes(expected), + `expected the "${word}" label to be wrapped in ${name}; ` + + `looked for ${JSON.stringify(expected)} in ${JSON.stringify(line)}`); + }); +} diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/build.js b/packages/logger/test/lib/writers/interactiveConsole/state/build.js new file mode 100644 index 00000000000..f7977093d2a --- /dev/null +++ b/packages/logger/test/lib/writers/interactiveConsole/state/build.js @@ -0,0 +1,121 @@ +import test from "ava"; + +import { + createBuildState, + beginBuild, + advanceToProject, + setTask, + transitionTo, + setError, + enableBuildPlaceholders, + STATES, +} from "../../../../../lib/writers/interactiveConsole/state/build.js"; + +test("createBuildState: starts in INITIAL with empty counters", (t) => { + const state = createBuildState(); + t.is(state.state, STATES.INITIAL); + t.is(state.currentProjectIndex, 0); + t.is(state.totalProjects, 0); + t.deepEqual(state.projectOrder, []); + t.deepEqual(state.changedProjects, []); + t.is(state.spinFrame, 0); + t.is(state.errorMessage, ""); + t.is(state.lastBuildHrtime, null); +}); + +test("beginBuild: sets projectOrder, totalProjects, and projectNameWidth", (t) => { + const state = createBuildState(); + beginBuild(state, ["a", "longer-name", "b"]); + t.deepEqual(state.projectOrder, ["a", "longer-name", "b"]); + t.is(state.totalProjects, 3); + t.is(state.projectNameWidth, "longer-name".length, + "width matches the longest known project so status-line updates don't reflow"); + // Transient counters are reset. + t.is(state.currentProjectIndex, 0); + t.is(state.currentProjectName, ""); + t.is(state.currentTaskName, ""); + t.is(state.spinFrame, 0); +}); + +test("beginBuild: accepts an iterable and materialises it into an array", (t) => { + const state = createBuildState(); + beginBuild(state, new Set(["a", "b"])); + t.deepEqual(state.projectOrder, ["a", "b"]); + t.is(state.totalProjects, 2); +}); + +test("advanceToProject: maps a known project to its 1-based index", (t) => { + const state = createBuildState(); + beginBuild(state, ["a", "b", "c"]); + advanceToProject(state, "b"); + t.is(state.currentProjectIndex, 2); + t.is(state.currentProjectName, "b"); +}); + +test("advanceToProject: unknown projects increment the counter as a fallback", (t) => { + const state = createBuildState(); + beginBuild(state, ["known"]); + // A build-start for a project not announced via build-metadata falls + // back to ++currentProjectIndex so the counter still moves forward. + advanceToProject(state, "surprise"); + t.is(state.currentProjectIndex, 1); + t.is(state.currentProjectName, "surprise"); +}); + +test("advanceToProject: clears the currentTaskName", (t) => { + const state = createBuildState(); + beginBuild(state, ["a", "b"]); + advanceToProject(state, "a"); + setTask(state, "minify"); + advanceToProject(state, "b"); + t.is(state.currentTaskName, "", "task name is cleared for the next project"); +}); + +test("setTask: taskNameWidth never shrinks", (t) => { + const state = createBuildState(); + setTask(state, "minifyAndBundle"); + t.is(state.taskNameWidth, "minifyAndBundle".length); + setTask(state, "css"); + // The column width holds at the widest name seen, so a re-render doesn't + // leave stray characters from the previous longer name behind. + t.is(state.taskNameWidth, "minifyAndBundle".length, + "width holds when a shorter task follows"); + t.is(state.currentTaskName, "css"); +}); + +test("transitionTo: sets the state and resets the spinner frame", (t) => { + const state = createBuildState(); + state.spinFrame = 5; + transitionTo(state, STATES.BUILDING); + t.is(state.state, STATES.BUILDING); + t.is(state.spinFrame, 0, "spinner restarts when the state changes"); +}); + +test("setError: transitions to ERROR and stores the message", (t) => { + const state = createBuildState(); + setError(state, "boom"); + t.is(state.state, STATES.ERROR); + t.is(state.errorMessage, "boom"); +}); + +test("setError: falsy message resolves to an empty string", (t) => { + const state = createBuildState(); + setError(state, undefined); + t.is(state.state, STATES.ERROR); + t.is(state.errorMessage, ""); +}); + +test("enableBuildPlaceholders: promotes INITIAL to STARTING", (t) => { + const state = createBuildState(); + enableBuildPlaceholders(state); + t.is(state.state, STATES.STARTING); +}); + +test("enableBuildPlaceholders: leaves non-INITIAL states alone", (t) => { + // Real state must win: if a build has already reported progress, the + // placeholder is stale and must not overwrite it. + const state = createBuildState(); + transitionTo(state, STATES.BUILDING); + enableBuildPlaceholders(state); + t.is(state.state, STATES.BUILDING); +}); diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/header.js b/packages/logger/test/lib/writers/interactiveConsole/state/header.js new file mode 100644 index 00000000000..b76d09ab934 --- /dev/null +++ b/packages/logger/test/lib/writers/interactiveConsole/state/header.js @@ -0,0 +1,24 @@ +import test from "ava"; + +import {createHeaderState, setTool} from + "../../../../../lib/writers/interactiveConsole/state/header.js"; + +test("createHeaderState: tool starts as null", (t) => { + t.deepEqual(createHeaderState(), {tool: null}); +}); + +test("setTool: stores a shallow copy of the incoming payload", (t) => { + const state = createHeaderState(); + setTool(state, {name: "UI5 CLI", version: "1.0.0", extraNoise: "dropped"}); + t.deepEqual(state.tool, {name: "UI5 CLI", version: "1.0.0"}, + "only the two documented fields are retained"); +}); + +test("setTool: clearing with a falsy value resets tool to null", (t) => { + // The writer only ever calls setTool with a payload today, but the branch + // exists so future callers can wipe the region if the tool identity changes. + const state = createHeaderState(); + setTool(state, {name: "UI5 CLI", version: "1.0.0"}); + setTool(state, null); + t.is(state.tool, null); +}); diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/project.js b/packages/logger/test/lib/writers/interactiveConsole/state/project.js new file mode 100644 index 00000000000..e1f08b79585 --- /dev/null +++ b/packages/logger/test/lib/writers/interactiveConsole/state/project.js @@ -0,0 +1,37 @@ +import test from "ava"; + +import {createProjectState, setProject, enableProjectPlaceholders} from + "../../../../../lib/writers/interactiveConsole/state/project.js"; + +test("createProjectState: fresh state has no project/framework and placeholders disabled", (t) => { + t.deepEqual(createProjectState(), { + project: null, + framework: null, + showPlaceholders: false, + }); +}); + +test("setProject: keeps only {name, type, version} from the incoming event", (t) => { + const state = createProjectState(); + setProject(state, { + 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"}); +}); + +test("setProject: 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}); + t.is(state.framework, null); +}); + +test("enableProjectPlaceholders: flips the showPlaceholders flag", (t) => { + const state = createProjectState(); + enableProjectPlaceholders(state); + t.true(state.showPlaceholders); +}); diff --git a/packages/logger/test/lib/writers/interactiveConsole/state/server.js b/packages/logger/test/lib/writers/interactiveConsole/state/server.js new file mode 100644 index 00000000000..1af9ce2958d --- /dev/null +++ b/packages/logger/test/lib/writers/interactiveConsole/state/server.js @@ -0,0 +1,63 @@ +import test from "ava"; + +import {createServerState, setListening, enableServerPlaceholders} from + "../../../../../lib/writers/interactiveConsole/state/server.js"; + +test("createServerState: fresh state has no urls and placeholders disabled", (t) => { + t.deepEqual(createServerState(), { + urls: null, + acceptRemoteConnections: false, + showPlaceholders: false, + }); +}); + +test("setListening: retains only {label, url} and coerces acceptRemoteConnections to boolean", (t) => { + const state = createServerState(); + setListening(state, { + urls: [ + {label: "Local", url: "http://localhost:8080", extra: "dropped"}, + {label: "Network", url: "http://10.0.0.1:8080"}, + ], + acceptRemoteConnections: 1, + }); + t.deepEqual(state.urls, [ + {label: "Local", url: "http://localhost:8080"}, + {label: "Network", url: "http://10.0.0.1:8080"}, + ]); + t.true(state.acceptRemoteConnections, "truthy value is coerced to true"); +}); + +test("setListening: non-array urls fall back to an empty list", (t) => { + // Defensive coercion — a broken caller must not leave the region in a + // half-populated state that #renderServerRegion cannot iterate. + const state = createServerState(); + setListening(state, {urls: undefined, acceptRemoteConnections: true}); + t.deepEqual(state.urls, []); + t.true(state.acceptRemoteConnections); +}); + +test("enableServerPlaceholders: flips the flag without touching acceptRemoteConnections by default", (t) => { + const state = createServerState(); + // Pre-existing value must survive when the caller doesn't supply one. + state.acceptRemoteConnections = true; + enableServerPlaceholders(state); + t.true(state.showPlaceholders); + t.true(state.acceptRemoteConnections, "existing flag survives when no override is passed"); +}); + +test("enableServerPlaceholders: accepts a boolean override for acceptRemoteConnections", (t) => { + const state = createServerState(); + enableServerPlaceholders(state, {acceptRemoteConnections: true}); + t.true(state.showPlaceholders); + t.true(state.acceptRemoteConnections); +}); + +test("enableServerPlaceholders: non-boolean override is ignored", (t) => { + // Only strict boolean overrides are honoured; anything else preserves the + // state's own default so we don't accidentally paint the warning block for + // e.g. `undefined` from a caller that forgot to pass the field. + const state = createServerState(); + enableServerPlaceholders(state, {acceptRemoteConnections: "yes"}); + t.true(state.showPlaceholders); + t.false(state.acceptRemoteConnections, "non-boolean override does not switch the flag"); +}); From 8cdb0fa4bc0df19a9afba1b285b52d2d224c304d Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 15:08:09 +0200 Subject: [PATCH 15/16] test(cli): Cover interactive writer branch in logger middleware --- .../cli/test/lib/cli/middlewares/logger.js | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/lib/cli/middlewares/logger.js b/packages/cli/test/lib/cli/middlewares/logger.js index 6733ef25b64..3c4892cb03c 100644 --- a/packages/cli/test/lib/cli/middlewares/logger.js +++ b/packages/cli/test/lib/cli/middlewares/logger.js @@ -7,9 +7,14 @@ test.beforeEach(async (t) => { t.context.verboseLogStub = sinon.stub(); t.context.setLogLevelStub = sinon.stub(); t.context.isLogLevelEnabledStub = sinon.stub().returns(true); + t.context.getLogLevelStub = sinon.stub().returns("info"); + t.context.getVersionStub = sinon.stub().returns("1.2.3"); t.context.getVersionWithLocationStub = sinon.stub().returns("1.0.0 (from /path/to/ui5.cjs)"); - t.context.logger = await esmock("../../../../lib/cli/middlewares/logger.js", { + t.context.consoleInitStub = sinon.stub(); + t.context.interactiveInitStub = sinon.stub(); + t.context.logger = await esmock.p("../../../../lib/cli/middlewares/logger.js", { "../../../../lib/cli/version.js": { + getVersion: t.context.getVersionStub, getVersionWithLocation: t.context.getVersionWithLocationStub }, "@ui5/logger": { @@ -18,10 +23,24 @@ test.beforeEach(async (t) => { }), setLogLevel: t.context.setLogLevelStub, isLogLevelEnabled: t.context.isLogLevelEnabledStub, + getLogLevel: t.context.getLogLevelStub, + }, + "@ui5/logger/writers/Console": { + default: {init: t.context.consoleInitStub} + }, + "@ui5/logger/writers/InteractiveConsole": { + default: {init: t.context.interactiveInitStub} } }); }); +test.afterEach.always((t) => { + sinon.restore(); + if (t.context.logger) { + esmock.purge(t.context.logger); + } +}); + test.serial("init logger", async (t) => { const {logger, setLogLevelStub, isLogLevelEnabledStub, verboseLogStub, getVersionWithLocationStub} = t.context; await logger.initLogger({}); @@ -89,6 +108,113 @@ test.serial("With log-level, verbose, perf and silent flag", async (t) => { t.is(setLogLevelStub.getCall(3).args[0], "silly", "sets log level to verbose"); }); +// Helper: swap process.stderr.isTTY and UI5_CLI_NO_INTERACTIVE for the duration +// of a single test, restoring both when done. Interactive-writer selection is +// derived from these two globals, so a targeted helper keeps each test focused +// on the branch it exercises. +function withInteractiveEnv({isTTY, noInteractive}, run) { + const originalIsTTY = process.stderr.isTTY; + const originalNoInteractive = process.env.UI5_CLI_NO_INTERACTIVE; + process.stderr.isTTY = isTTY; + if (noInteractive === undefined) { + delete process.env.UI5_CLI_NO_INTERACTIVE; + } else { + process.env.UI5_CLI_NO_INTERACTIVE = noInteractive; + } + return Promise.resolve() + .then(run) + .finally(() => { + process.stderr.isTTY = originalIsTTY; + if (originalNoInteractive === undefined) { + delete process.env.UI5_CLI_NO_INTERACTIVE; + } else { + process.env.UI5_CLI_NO_INTERACTIVE = originalNoInteractive; + } + }); +} + +test.serial("Interactive writer: enabled for 'serve' on TTY with interactive log level", async (t) => { + const {logger, consoleInitStub, interactiveInitStub, getVersionStub} = t.context; + const emitStub = sinon.stub(process, "emit"); + try { + await withInteractiveEnv({isTTY: true, noInteractive: undefined}, () => + logger.initLogger({_: ["serve"]}) + ); + } finally { + emitStub.restore(); + } + t.is(interactiveInitStub.callCount, 1, "InteractiveConsole.init called"); + t.is(consoleInitStub.callCount, 0, "Plain ConsoleWriter.init not called"); + t.is(getVersionStub.callCount, 1, "getVersion queried for tool-info event"); + const toolInfoCall = emitStub.getCalls().find((c) => c.args[0] === "ui5.tool-info"); + t.truthy(toolInfoCall, "ui5.tool-info event emitted"); + t.deepEqual(toolInfoCall.args[1], {name: "UI5 CLI", version: "1.2.3"}, + "tool-info payload matches expected"); +}); + +test.serial("Interactive writer: falls back to '' when getVersion returns undefined", async (t) => { + const {logger, interactiveInitStub, getVersionStub} = t.context; + getVersionStub.returns(undefined); + const emitStub = sinon.stub(process, "emit"); + try { + await withInteractiveEnv({isTTY: true, noInteractive: undefined}, () => + logger.initLogger({_: ["serve"]}) + ); + } finally { + emitStub.restore(); + } + t.is(interactiveInitStub.callCount, 1, "InteractiveConsole.init called"); + const toolInfoCall = emitStub.getCalls().find((c) => c.args[0] === "ui5.tool-info"); + t.deepEqual(toolInfoCall.args[1], {name: "UI5 CLI", version: ""}, + "tool-info payload uses empty string when version unknown"); +}); + +test.serial("Interactive writer: skipped for non-serve command", async (t) => { + const {logger, consoleInitStub, interactiveInitStub} = t.context; + await withInteractiveEnv({isTTY: true, noInteractive: undefined}, () => + logger.initLogger({_: ["build"]}) + ); + t.is(interactiveInitStub.callCount, 0, "InteractiveConsole.init not called"); + t.is(consoleInitStub.callCount, 1, "Plain ConsoleWriter.init called instead"); +}); + +test.serial("Interactive writer: skipped when stderr is not a TTY", async (t) => { + const {logger, consoleInitStub, interactiveInitStub} = t.context; + await withInteractiveEnv({isTTY: false, noInteractive: undefined}, () => + logger.initLogger({_: ["serve"]}) + ); + t.is(interactiveInitStub.callCount, 0, "InteractiveConsole.init not called"); + t.is(consoleInitStub.callCount, 1, "Plain ConsoleWriter.init called instead"); +}); + +test.serial("Interactive writer: skipped for non-interactive log level (verbose)", async (t) => { + const {logger, consoleInitStub, interactiveInitStub, getLogLevelStub} = t.context; + getLogLevelStub.returns("verbose"); + await withInteractiveEnv({isTTY: true, noInteractive: undefined}, () => + logger.initLogger({_: ["serve"]}) + ); + t.is(interactiveInitStub.callCount, 0, "InteractiveConsole.init not called"); + t.is(consoleInitStub.callCount, 1, "Plain ConsoleWriter.init called instead"); +}); + +test.serial("Interactive writer: skipped when UI5_CLI_NO_INTERACTIVE is set", async (t) => { + const {logger, consoleInitStub, interactiveInitStub} = t.context; + await withInteractiveEnv({isTTY: true, noInteractive: "1"}, () => + logger.initLogger({_: ["serve"]}) + ); + t.is(interactiveInitStub.callCount, 0, "InteractiveConsole.init not called"); + t.is(consoleInitStub.callCount, 1, "Plain ConsoleWriter.init called instead"); +}); + +test.serial("Interactive writer: skipped when argv._ is not an array", async (t) => { + const {logger, consoleInitStub, interactiveInitStub} = t.context; + await withInteractiveEnv({isTTY: true, noInteractive: undefined}, () => + logger.initLogger({}) + ); + t.is(interactiveInitStub.callCount, 0, "InteractiveConsole.init not called"); + t.is(consoleInitStub.callCount, 1, "Plain ConsoleWriter.init called instead"); +}); + import path from "node:path"; import {execa} from "execa"; import {readFileSync} from "node:fs"; From ac1e4ee06e474720bdde26cea648e9af7a941243 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 3 Jul 2026 15:17:20 +0200 Subject: [PATCH 16/16] test(logger): Tolerate figures Windows fallback in starting-state assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Status\s+\S+\s+starting` regex assumed a single non-space glyph between the "Status" label and the state word. On Windows without a Unicode-capable terminal (e.g. GitHub Actions runners), figures.circle falls back to "( )" — a three-character sequence containing a space — so \S+ can no longer span the whole glyph and the assertion fails. Loosen the pattern to `.+?`, matching the convention already used by the ready/stale/building/error assertions in the same file. --- packages/logger/test/lib/writers/InteractiveConsole.js | 2 +- packages/logger/test/lib/writers/interactiveConsole/render.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/logger/test/lib/writers/InteractiveConsole.js b/packages/logger/test/lib/writers/InteractiveConsole.js index 33ff8f4e8bb..0fea70ced26 100644 --- a/packages/logger/test/lib/writers/InteractiveConsole.js +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -284,7 +284,7 @@ test.serial("tool-mode 'serve' enables placeholders for project, server, and sta t.regex(output, /Local:\s+binding…/, "server local placeholder rendered"); t.regex(output, /Network:\s+use --accept-remote-connections to expose/, "network hint rendered when the flag isn't set"); - t.regex(output, /Status\s+\S+\s+starting/, "status starting placeholder rendered"); + t.regex(output, /Status\s+.+?\s+starting/, "status starting placeholder rendered"); const state = writer._getStateForTest(); t.true(state.project.showPlaceholders); diff --git a/packages/logger/test/lib/writers/interactiveConsole/render.js b/packages/logger/test/lib/writers/interactiveConsole/render.js index 9d2c86dbd49..2b3ab47ce94 100644 --- a/packages/logger/test/lib/writers/interactiveConsole/render.js +++ b/packages/logger/test/lib/writers/interactiveConsole/render.js @@ -221,7 +221,7 @@ test("renderBuildRegion: starting state renders a dim placeholder", (t) => { const state = createBuildState(); transitionTo(state, STATES.STARTING); const plain = renderBuildRegion(state).map(stripAnsi).join("\n"); - t.regex(plain, /Status\s+\S+\s+starting/); + t.regex(plain, /Status\s+.+?\s+starting/); }); test("renderBuildRegion: ready state", (t) => {