From 5ca3112f9b11355d020be32b1c2268797d9cf30e Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Thu, 18 Dec 2025 08:47:22 +0100 Subject: [PATCH 1/3] feat(cli): Add CPU profiling support Profiling can be enabled via the `UI5_CLI_PROFILE` environment variable. --- packages/cli/bin/ui5.cjs | 13 ++++- packages/cli/lib/cli/cli.js | 7 +-- packages/cli/lib/utils/profile.js | 82 +++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 packages/cli/lib/utils/profile.js diff --git a/packages/cli/bin/ui5.cjs b/packages/cli/bin/ui5.cjs index 9ded92bba5a..12942c8fc9c 100755 --- a/packages/cli/bin/ui5.cjs +++ b/packages/cli/bin/ui5.cjs @@ -94,8 +94,19 @@ const ui5 = { }, async invokeCLI(pkg) { + let profile; + if (process.env.UI5_CLI_PROFILE) { + profile = await import("../lib/utils/profile.js"); + await profile.start(); + } const {default: cli} = await import("../lib/cli/cli.js"); - await cli(pkg); + const {command} = await cli(pkg); + + // Stop profiling after CLI finished execution + // Except for "serve" command, which continues running and only stops on sigint (see profile.js) + if (profile && command !== "serve") { + await profile.stop(); + } }, async main() { diff --git a/packages/cli/lib/cli/cli.js b/packages/cli/lib/cli/cli.js index e46a519368c..b37c2fc36eb 100644 --- a/packages/cli/lib/cli/cli.js +++ b/packages/cli/lib/cli/cli.js @@ -68,7 +68,8 @@ export default async (pkg) => { // Format terminal output to full available width cli.wrap(cli.terminalWidth()); - // yargs registers a get method on the argv property. - // The property needs to be accessed to initialize everything. - cli.argv; + const {_} = await cli.argv; + return { + command: _[0] + }; }; diff --git a/packages/cli/lib/utils/profile.js b/packages/cli/lib/utils/profile.js new file mode 100644 index 00000000000..2e78664664e --- /dev/null +++ b/packages/cli/lib/utils/profile.js @@ -0,0 +1,82 @@ +import {writeFileSync} from "node:fs"; +import {Session} from "node:inspector/promises"; + +let session; +let processSignals; + +export async function start() { + if (session) { + return; + } + session = new Session(); + session.connect(); + await session.post("Profiler.enable"); + await session.post("Profiler.start"); + console.log(`Recording CPU profile...`); + processSignals = registerSigHooks(); +} + +async function writeProfile(profile) { + const formatter = new Intl.DateTimeFormat("en-GB", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const dateParts = Object.create(null); + const parts = formatter.formatToParts(new Date()); + parts.forEach((p) => { + dateParts[p.type] = p.value; + }); + + const fileName = `./ui5_${dateParts.year}-${dateParts.month}-${dateParts.day}_` + + `${dateParts.hour}-${dateParts.minute}-${dateParts.second}.cpuprofile`; + console.log(`\nSaving CPU profile to ${fileName}...`); + writeFileSync(fileName, JSON.stringify(profile)); +} + +export async function stop() { + if (!session) { + return; + } + const {profile} = await session.post("Profiler.stop"); + session = null; + if (profile) { + await writeProfile(profile); + } + if (processSignals) { + deregisterSigHooks(processSignals); + processSignals = null; + } +} + +function registerSigHooks() { + function createListener(exitCode) { + return function() { + // Gracefully end profiling, then exit + stop().then(() => { + process.exit(exitCode); + }); + }; + } + + const processSignals = { + "SIGHUP": createListener(128 + 1), + "SIGINT": createListener(128 + 2), + "SIGTERM": createListener(128 + 15), + "SIGBREAK": createListener(128 + 21) + }; + + for (const signal of Object.keys(processSignals)) { + process.on(signal, processSignals[signal]); + } + return processSignals; +} + +function deregisterSigHooks(signals) { + for (const signal of Object.keys(signals)) { + process.removeListener(signal, signals[signal]); + } +} From 4778106d60d7620d48980c24dea52c415dfc8179 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Fri, 2 Jan 2026 10:46:22 +0100 Subject: [PATCH 2/3] style(cli): Allow console.log in profile.js --- packages/cli/lib/utils/profile.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/lib/utils/profile.js b/packages/cli/lib/utils/profile.js index 2e78664664e..295bc8789e3 100644 --- a/packages/cli/lib/utils/profile.js +++ b/packages/cli/lib/utils/profile.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import {writeFileSync} from "node:fs"; import {Session} from "node:inspector/promises"; From 2152f55b53f3a2bb850dfad5852bb0e6c941b6e7 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Fri, 2 Jan 2026 11:14:55 +0100 Subject: [PATCH 3/3] refactor: Fix and improve tests/impl --- packages/cli/bin/ui5.cjs | 4 ++-- packages/cli/lib/cli/cli.js | 5 +---- packages/cli/test/lib/cli/cli.js | 23 ++++++++++------------- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/packages/cli/bin/ui5.cjs b/packages/cli/bin/ui5.cjs index 12942c8fc9c..187e609d603 100755 --- a/packages/cli/bin/ui5.cjs +++ b/packages/cli/bin/ui5.cjs @@ -100,11 +100,11 @@ const ui5 = { await profile.start(); } const {default: cli} = await import("../lib/cli/cli.js"); - const {command} = await cli(pkg); + const argv = await cli(pkg); // Stop profiling after CLI finished execution // Except for "serve" command, which continues running and only stops on sigint (see profile.js) - if (profile && command !== "serve") { + if (profile && argv._[0] !== "serve") { await profile.stop(); } }, diff --git a/packages/cli/lib/cli/cli.js b/packages/cli/lib/cli/cli.js index b37c2fc36eb..7366bc321ad 100644 --- a/packages/cli/lib/cli/cli.js +++ b/packages/cli/lib/cli/cli.js @@ -68,8 +68,5 @@ export default async (pkg) => { // Format terminal output to full available width cli.wrap(cli.terminalWidth()); - const {_} = await cli.argv; - return { - command: _[0] - }; + return cli.parse(); }; diff --git a/packages/cli/test/lib/cli/cli.js b/packages/cli/test/lib/cli/cli.js index c6718bc0ed2..1854656a237 100644 --- a/packages/cli/test/lib/cli/cli.js +++ b/packages/cli/test/lib/cli/cli.js @@ -11,7 +11,8 @@ test.beforeEach(async (t) => { notify: t.context.updateNotifierNotify }).named("updateNotifier"); - t.context.argvGetter = sinon.stub(); + t.context.yargsHideBin = sinon.stub().named("hideBin").returns([]); + t.context.yargsInstance = { parserConfiguration: sinon.stub(), version: sinon.stub(), @@ -19,10 +20,7 @@ test.beforeEach(async (t) => { command: sinon.stub(), terminalWidth: sinon.stub().returns(123), wrap: sinon.stub(), - get argv() { - t.context.argvGetter(); - return undefined; - } + parse: sinon.stub().resolves({_: []}) }; t.context.yargs = sinon.stub().returns(t.context.yargsInstance).named("yargs"); @@ -54,10 +52,9 @@ test.beforeEach(async (t) => { t.context.cli = await esmock.p("../../../lib/cli/cli.js", { "update-notifier": t.context.updateNotifier, "yargs": t.context.yargs, - // TODO: Somehow esmock is unable to mock this import - // "yargs/helpers": { - // hideBin: t.context.yargsHideBin - // }, + "yargs/helpers": { + hideBin: t.context.yargsHideBin + }, "../../../lib/cli/version.js": { setVersion: t.context.setVersion }, @@ -76,7 +73,7 @@ test.afterEach.always((t) => { test.serial("CLI", async (t) => { const { - cli, updateNotifier, updateNotifierNotify, argvGetter, yargsInstance, yargs, + cli, updateNotifier, updateNotifierNotify, yargsInstance, yargs, setVersion, cliBase } = t.context; @@ -131,8 +128,8 @@ test.serial("CLI", async (t) => { t.is(yargsInstance.wrap.callCount, 1); t.deepEqual(yargsInstance.wrap.getCall(0).args, [123]); - t.is(argvGetter.callCount, 1); - t.deepEqual(argvGetter.getCall(0).args, []); + t.is(yargsInstance.parse.callCount, 1); + t.deepEqual(yargsInstance.parse.getCall(0).args, []); sinon.assert.callOrder( updateNotifier, @@ -146,7 +143,7 @@ test.serial("CLI", async (t) => { yargsInstance.command, yargsInstance.terminalWidth, yargsInstance.wrap, - argvGetter + yargsInstance.parse ); });