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. diff --git a/package-lock.json b/package-lock.json index cce2c4f4624..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" }, @@ -18475,7 +18472,10 @@ "dependencies": { "chalk": "^5.6.2", "cli-progress": "^3.12.0", - "figures": "^6.1.0" + "figures": "^6.1.0", + "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/lib/cli/commands/serve.js b/packages/cli/lib/cli/commands/serve.js index e58b4158316..3a21e227b7f 100644 --- a/packages/cli/lib/cli/commands/serve.js +++ b/packages/cli/lib/cli/commands/serve.js @@ -1,36 +1,11 @@ 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 {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,31 +135,16 @@ 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, - }); - } + // 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. The server's `ui5.server-listening` event later + // supplies the authoritative URLs. + process.emit("ui5.tool-mode", { + mode: "serve", + acceptRemoteConnections: !!argv.acceptRemoteConnections, + }); 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) { @@ -204,23 +164,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; @@ -267,42 +210,21 @@ 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); }); - 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) { + 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 ec9536d4a71..63f85ca6a2a 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,26 @@ 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()) && + !process.env.UI5_CLI_NO_INTERACTIVE; + + 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(); + } + if (isLogLevelEnabled("verbose")) { const log = getLogger("cli:middlewares:base"); log.verbose(`using @ui5/cli version ${getVersionWithLocation()}`); diff --git a/packages/cli/lib/serve/Banner.js b/packages/cli/lib/serve/Banner.js deleted file mode 100644 index 1ae08b05ec5..00000000000 --- a/packages/cli/lib/serve/Banner.js +++ /dev/null @@ -1,519 +0,0 @@ -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"), -}; - -// Spinner tick interval while in `building` state. -const BUILDING_TICK_MS = 120; - -// Decode the tail of `stream.write(chunk[, encoding][, callback])`. `encoding` -// falls back to "utf8" (Node's default) when not supplied. -function parseWriteArgs(encodingOrCallback, maybeCallback) { - if (typeof encodingOrCallback === "function") { - return {encoding: "utf8", callback: encodingOrCallback}; - } - return { - encoding: typeof encodingOrCallback === "string" ? encodingOrCallback : "utf8", - callback: typeof maybeCallback === "function" ? maybeCallback : undefined, - }; -} - -/** - * Live banner for `ui5 serve`. Owns process.stdout while active. - * - * 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. - */ -class Banner { - #stdout; - #stopped = false; - #state; - #layout = {projectNameWidth: 0, taskNameWidth: 0}; - #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 - // `#render()` call hands it the full multi-line frame and it diffs against - // the previous one, erasing wrap-correctly. - #logUpdate; - - // 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 - // stream. Any *other* write hits the interception path. - #renderingLiveRegion = false; - // Per-stream interception bookkeeping. Keyed by "stdout"/"stderr", each - // entry holds the saved original write method (or `null` when this stream - // is not being intercepted) and any partial (non-newline-terminated) bytes - // that arrived without a newline yet. - #streams = { - stdout: {orig: null, partial: ""}, - stderr: {orig: null, partial: ""}, - }; - - // Bound listeners so we can `process.off` them on stop(). - #onLog; - #onBuildMetadata; - #onBuildStatus; - #onProjectBuildStatus; - #onServeStatus; - #onStopConsole; - #onResize; - - /** - * Paint the banner skeleton immediately (with placeholders for any - * unknown sections) and attach the event listeners. Subsequent section - * setters refine the skeleton in place. - * - * @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} - */ - 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; - } - banner.#attachListeners(); - banner.#logUpdate = createLogUpdate(banner.#stdout); - banner.#render(); - banner.#scheduleTick(); - const wantsIntercept = opts.interceptProcessWrites !== false && - banner.#stdout === process.stdout; - if (wantsIntercept) { - banner.#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. - * - * @param {object} info { name, type, version, framework? } - */ - 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(); - } - - // 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. - #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"); - } - - #render() { - if (this.#stopped) { - return; - } - this.#withRenderingGuard(() => this.#logUpdate(this.#composeLiveRegion())); - } - - /** - * Write a log line ABOVE the live region so it persists in scrollback. - * - * 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. - * - * @param {string} line The log line to write above the live region. - */ - logAbove(line) { - if (this.#stopped) { - this.#stdout.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. - #withRenderingGuard(fn) { - this.#renderingLiveRegion = true; - try { - return fn(); - } finally { - this.#renderingLiveRegion = false; - } - } - - // ---- Event subscriptions -------------------------------------------------- - - #attachListeners() { - this.#onLog = (evt) => this.#handleLog(evt); - this.#onBuildMetadata = (evt) => this.#handleBuildMetadata(evt); - this.#onBuildStatus = (evt) => this.#handleBuildStatus(evt); - this.#onProjectBuildStatus = (evt) => this.#handleProjectBuildStatus(evt); - this.#onServeStatus = (evt) => this.#handleServeStatus(evt); - this.#onStopConsole = () => this.stop(); - this.#onResize = () => this.#handleResize(); - - process.on("ui5.log", this.#onLog); - process.on("ui5.build-metadata", this.#onBuildMetadata); - 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.log.stop-console", this.#onStopConsole); - if (typeof this.#stdout.on === "function") { - this.#stdout.on("resize", this.#onResize); - } - } - - #detachListeners() { - process.off("ui5.log", this.#onLog); - process.off("ui5.build-metadata", this.#onBuildMetadata); - 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.log.stop-console", this.#onStopConsole); - if (typeof this.#stdout.off === "function") { - this.#stdout.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 — - // 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. - if (level !== "warn" && level !== "error") { - return; - } - if (!Logger.isLevelEnabled(level)) { - return; - } - const levelPrefix = LEVEL_PREFIX[level]; - const formatted = moduleName ? - `${levelPrefix} ${chalk.blue(moduleName)} ${message}` : - `${levelPrefix} ${message}`; - this.logAbove(formatted); - } - - #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); - 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 = ""; - 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; - } - this.#render(); - } - } - - #handleServeStatus(evt) { - switch (evt.status) { - case "serve-ready": - this.#transitionTo(STATES.READY); - break; - case "serve-stale": - this.#state.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; - this.#transitionTo(STATES.BUILDING); - break; - case "serve-build-done": - this.#state.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); - // 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 - // stack trace once the handler promise rejects. A third copy here - // would just be noise. - break; - } - } - - #handleResize() { - this.#columns = this.#stdout.columns; - this.#render(); - } - - #transitionTo(newState) { - if (this.#state.state === newState) { - this.#render(); - return; - } - this.#state.state = newState; - this.#state.spinFrame = 0; - this.#clearTick(); - this.#scheduleTick(); - this.#render(); - } - - #scheduleTick() { - if (this.#stopped) { - return; - } - if (this.#state.state !== STATES.BUILDING) { - return; - } - this.#tickTimer = setInterval(() => { - this.#state.spinFrame++; - this.#render(); - }, BUILDING_TICK_MS); - // Don't keep the event loop alive purely for the banner spinner. - if (typeof this.#tickTimer.unref === "function") { - this.#tickTimer.unref(); - } - } - - #clearTick() { - if (this.#tickTimer) { - clearInterval(this.#tickTimer); - this.#tickTimer = null; - } - } - - // ---- 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 - // duplicating the header. To keep the live region intact we replace - // process.stdout.write / process.stderr.write while the banner is active - // and route incoming bytes through logAbove() line by line. log-update's - // own writes bypass the interception via the #renderingLiveRegion flag. - - #installWriteInterceptors() { - for (const which of ["stdout", "stderr"]) { - const entry = this.#streams[which]; - entry.orig = process[which].write; - process[which].write = this.#makeInterceptor(process[which], entry.orig, which); - } - } - - #uninstallWriteInterceptors() { - for (const which of ["stdout", "stderr"]) { - const entry = this.#streams[which]; - if (entry.orig) { - process[which].write = entry.orig; - entry.orig = null; - } - } - } - - #makeInterceptor(stream, original, which) { - // 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; - const lastNewline = combined.lastIndexOf("\n"); - if (lastNewline === -1) { - entry.partial = combined; - 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)); - } - - #flushPartialBuffers() { - for (const which of ["stdout", "stderr"]) { - const entry = this.#streams[which]; - if (entry.partial) { - const line = entry.partial; - entry.partial = ""; - this.logAbove(line); - } - } - } - - // ---- Test helpers --------------------------------------------------------- - - /* istanbul ignore next */ - _getStateForTest() { - return this.#state; - } -} - -export default Banner; 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 d13d18ee7b4..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, -// 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..bff07cc65f3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,14 +58,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" }, 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/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"; 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/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/lib/writers/Console.js b/packages/logger/lib/writers/Console.js index 846701cf807..b9587aa864e 100644 --- a/packages/logger/lib/writers/Console.js +++ b/packages/logger/lib/writers/Console.js @@ -3,6 +3,8 @@ 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"; +import {REMOTE_CONNECTIONS_WARNING_LINES} from "./interactiveConsole/remoteConnectionsWarning.js"; /** * Standard handler for events emitted by @ui5/logger modules. Writes messages to @@ -29,6 +31,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 +113,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 +129,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 +199,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 +435,49 @@ 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) { + // 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); + process.stderr.write("\n"); + } + process.stderr.write("\n"); + } + // 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) { + process.stdout.write(`Server started\nURL: ${localEntry.url}\n`); + } } } /** - * 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/logger/lib/writers/InteractiveConsole.js b/packages/logger/lib/writers/InteractiveConsole.js new file mode 100644 index 00000000000..5294c43eec6 --- /dev/null +++ b/packages/logger/lib/writers/InteractiveConsole.js @@ -0,0 +1,547 @@ +import process from "node:process"; +import {createLogUpdate} from "log-update"; +import sliceAnsi from "slice-ansi"; +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, enableProjectPlaceholders} from "./interactiveConsole/state/project.js"; +import {createServerState, setListening, enableServerPlaceholders} from "./interactiveConsole/state/server.js"; +import { + createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, STATES, + enableBuildPlaceholders, +} 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; + +// Decode the tail of `stream.write(chunk[, encoding][, callback])`. `encoding` +// falls back to "utf8" (Node's default) when not supplied. +function parseWriteArgs(encodingOrCallback, maybeCallback) { + if (typeof encodingOrCallback === "function") { + return {encoding: "utf8", callback: encodingOrCallback}; + } + return { + encoding: typeof encodingOrCallback === "string" ? encodingOrCallback : "utf8", + callback: typeof maybeCallback === "function" ? maybeCallback : undefined, + }; +} + +/** + * 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. + * + * @public + * @class + * @alias @ui5/logger/writers/InteractiveConsole + */ +class InteractiveConsole { + #stderr; + #stopped = false; + + #headerState; + #projectState; + #serverState; + #buildState; + + #tickTimer = null; + #columns; + // `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; + // 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 + // stream. Any *other* write hits the interception path. + #renderingLiveRegion = false; + // Per-stream interception bookkeeping. Keyed by "stdout"/"stderr", each + // entry holds the saved original write method (or `null` when this stream + // is not being intercepted) and any partial (non-newline-terminated) bytes + // that arrived without a newline yet. + #streams = { + stdout: {orig: null, partial: ""}, + stderr: {orig: null, partial: ""}, + }; + + #seenProjectResolved = false; + + // Bound listeners so we can `process.off` them on stop(). + #onLog; + #onBuildMetadata; + #onBuildStatus; + #onProjectBuildStatus; + #onServeStatus; + #onToolInfo; + #onToolMode; + #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; + } + + /** + * 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. + * + * @public + */ + enable() { + process.emit("ui5.log.stop-console"); + this.#stopped = false; + this.#attachListeners(); + if (!this.#logUpdate) { + this.#logUpdate = createLogUpdate(this.#stderr); + } + const wantsIntercept = this.#stderr === process.stderr; + if (wantsIntercept) { + this.#installWriteInterceptors(); + } + } + + /** + * Detaches all event listeners and stops writing to the output stream. + * + * @public + */ + 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 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 = [ + ...renderHeaderRegion(this.#headerState), + ...renderProjectRegion(this.#projectState), + ...renderServerRegion(this.#serverState), + ...renderBuildRegion(this.#buildState), + ]; + if (lines.length === 0) { + 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 + // 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 {frame: lines.join("\n"), lineCount: lines.length}; + } + + #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; + } + this.#withRenderingGuard(() => { + 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 + // 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. + if (this.#lastFrameLineCount !== undefined && lineCount !== this.#lastFrameLineCount) { + this.#logUpdate.clear(); + } + this.#lastFrameLineCount = lineCount; + this.#logUpdate(frame); + }); + } + + /** + * Write a log line ABOVE the live region so it persists in scrollback. + * + * 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. + * + * @param {string} line The log line to write above the live region. + */ + logAbove(line) { + if (this.#stopped) { + this.#stderr.write(line + "\n"); + return; + } + this.#withRenderingGuard(() => { + this.#logUpdate.persist(line); + // `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, lineCount} = this.#composeLiveRegion(); + this.#lastFrameLineCount = lineCount; + this.#logUpdate(frame); + }); + } + + // 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. + #withRenderingGuard(fn) { + this.#renderingLiveRegion = true; + try { + return fn(); + } finally { + this.#renderingLiveRegion = false; + } + } + + // ---- Event subscriptions -------------------------------------------------- + + #attachListeners() { + this.#onLog = (evt) => this.#handleLog(evt); + this.#onBuildMetadata = (evt) => this.#handleBuildMetadata(evt); + this.#onBuildStatus = (evt) => this.#handleBuildStatus(evt); + 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(); + this.#onResize = () => this.#handleResize(); + + process.on("ui5.log", this.#onLog); + process.on("ui5.build-metadata", this.#onBuildMetadata); + 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.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); + if (typeof this.#stderr.on === "function") { + this.#stderr.on("resize", this.#onResize); + } + } + + #detachListeners() { + process.off("ui5.log", this.#onLog); + process.off("ui5.build-metadata", this.#onBuildMetadata); + 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.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); + 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 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`; + // 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 = getLevelPrefix(level); + const formatted = moduleName ? + `${levelPrefix} ${chalk.blue(moduleName)} ${message}` : + `${levelPrefix} ${message}`; + this.logAbove(formatted); + } + + #handleToolInfo(evt) { + setTool(this.#headerState, evt); + 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, + }); + enableBuildPlaceholders(this.#buildState); + 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}) { + beginBuild(this.#buildState, projectsToBuild); + this.#render(); + } + + #handleBuildStatus({projectName, status}) { + if (status === "project-build-start" || status === "project-build-skip") { + advanceToProject(this.#buildState, projectName); + this.#render(); + } + } + + #handleProjectBuildStatus({taskName, status}) { + if (status === "task-start") { + setTask(this.#buildState, taskName); + this.#render(); + } + } + + #handleServeStatus(evt) { + switch (evt.status) { + case "serve-ready": + this.#transitionTo(STATES.READY); + break; + case "serve-stale": + this.#buildState.changedProjects = evt.changedProjects || []; + this.#transitionTo(STATES.STALE); + break; + case "serve-building": + beginBuild(this.#buildState, this.#buildState.projectOrder); + this.#transitionTo(STATES.BUILDING); + break; + case "serve-build-done": + this.#buildState.lastBuildHrtime = Array.isArray(evt.hrtime) ? evt.hrtime : null; + this.#transitionTo(STATES.READY); + break; + case "serve-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 + // stack trace once the handler promise rejects. A third copy here + // would just be noise. + break; + } + } + + #handleResize() { + this.#columns = this.#stderr.columns; + this.#render(); + } + + #transitionTo(newState) { + if (this.#buildState.state === newState) { + this.#render(); + return; + } + transitionTo(this.#buildState, newState); + this.#clearTick(); + this.#scheduleTick(); + this.#render(); + } + + #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; + } + if (this.#buildState.state !== STATES.BUILDING) { + return; + } + this.#tickTimer = setInterval(() => { + this.#buildState.spinFrame++; + this.#render(); + }, BUILDING_TICK_MS); + // Don't keep the event loop alive purely for the banner spinner. + if (typeof this.#tickTimer.unref === "function") { + this.#tickTimer.unref(); + } + } + + #clearTick() { + if (this.#tickTimer) { + clearInterval(this.#tickTimer); + this.#tickTimer = null; + } + } + + // ---- 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 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 writer is active + // and route incoming bytes through logAbove() line by line. log-update's + // own writes bypass the interception via the #renderingLiveRegion flag. + + #installWriteInterceptors() { + for (const which of ["stdout", "stderr"]) { + const entry = this.#streams[which]; + entry.orig = process[which].write; + process[which].write = this.#makeInterceptor(process[which], entry.orig, which); + } + } + + #uninstallWriteInterceptors() { + for (const which of ["stdout", "stderr"]) { + const entry = this.#streams[which]; + if (entry.orig) { + process[which].write = entry.orig; + entry.orig = null; + } + } + } + + #makeInterceptor(stream, original, which) { + // The returned function mirrors WriteStream#write's overloads: + // write(chunk[, encoding][, callback]) -> boolean + return (chunk, encodingOrCallback, maybeCallback) => { + if (this.#renderingLiveRegion || this.#stopped) { + return original.call(stream, chunk, encodingOrCallback, maybeCallback); + } + const {encoding, callback} = parseWriteArgs(encodingOrCallback, maybeCallback); + const text = typeof chunk === "string" ? + chunk : + (Buffer.isBuffer(chunk) ? chunk.toString(encoding) : Buffer.from(chunk).toString(encoding)); + this.#absorbInterceptedText(text, which); + if (callback) { + process.nextTick(callback); + } + return true; + }; + } + + #absorbInterceptedText(text, which) { + const entry = this.#streams[which]; + const combined = entry.partial + text; + const lastNewline = combined.lastIndexOf("\n"); + if (lastNewline === -1) { + entry.partial = combined; + return; + } + entry.partial = combined.slice(lastNewline + 1); + this.logAbove(combined.slice(0, lastNewline)); + } + + #flushPartialBuffers() { + for (const which of ["stdout", "stderr"]) { + const entry = this.#streams[which]; + if (entry.partial) { + const line = entry.partial; + entry.partial = ""; + this.logAbove(line); + } + } + } + + /** + * 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 { + header: this.#headerState, + project: this.#projectState, + server: this.#serverState, + build: this.#buildState, + }; + } +} + +// 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..2aa2d2322a3 --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/render.js @@ -0,0 +1,182 @@ +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. 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) { + 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 && !projectState.showPlaceholders) { + return []; + } + const lines = [""]; + 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 { + // 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 && !serverState.showPlaceholders) { + return []; + } + const lines = [""]; + + 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 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)}`); + } + 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 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) { + lines.push(`${arrow} ${accentBold("Network:")} ${placeholder("binding…")}`); + } else { + lines.push(`${arrow} ${accentBold("Network:")} ` + + chalk.dim("use --accept-remote-connections to expose")); + } + } + + // `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) { + lines.push(line); + } + } + return lines; +} + +export function renderBuildRegion(buildState) { + if (buildState.state === STATES.INITIAL) { + return []; + } + return ["", renderStatusLine(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) { + 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..e62fd6f4aad --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/state/build.js @@ -0,0 +1,94 @@ +// 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", + STARTING: "starting", // pre-populated placeholder before the first real state arrives + 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. +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); +} + +// 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 enableBuildPlaceholders(state) { + if (state.state === STATES.INITIAL) { + state.state = STATES.STARTING; + } +} 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..1f699c0251d --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/state/header.js @@ -0,0 +1,10 @@ +// 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; +} 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..f92a81622fa --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/state/project.js @@ -0,0 +1,20 @@ +// Region 2 — root project. Populated by `ui5.project-resolved`. +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, + }; +} + +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 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 new file mode 100644 index 00000000000..1f678ebb977 --- /dev/null +++ b/packages/logger/lib/writers/interactiveConsole/state/server.js @@ -0,0 +1,23 @@ +// Region 4 — server. Populated by `ui5.server-listening`. +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` fine-tunes what gets reserved. + showPlaceholders: 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 enableServerPlaceholders(state, {acceptRemoteConnections} = {}) { + state.showPlaceholders = true; + if (typeof acceptRemoteConnections === "boolean") { + state.acceptRemoteConnections = acceptRemoteConnections; + } +} 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 5286b7452d6..bdbd68dc667 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -50,7 +50,10 @@ "dependencies": { "chalk": "^5.6.2", "cli-progress": "^3.12.0", - "figures": "^6.1.0" + "figures": "^6.1.0", + "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/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"); }); 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..18715214744 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(); @@ -1117,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 new file mode 100644 index 00000000000..0fea70ced26 --- /dev/null +++ b/packages/logger/test/lib/writers/InteractiveConsole.js @@ -0,0 +1,896 @@ +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(); +}); + +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+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 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, + }); + + const output = stripAnsi(stderr.writes.join("")); + // One Local placeholder + one Network placeholder + const bindingCount = (output.match(/binding…/g) || []).length; + t.is(bindingCount, 2, "one Local placeholder + one Network placeholder"); + 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/); +}); + +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..2b3ab47ce94 --- /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+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"); +}); 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; +}