diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index aff2f642c0c..a55478aaecc 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -26,22 +26,27 @@ Only remove these directories when no UI5 CLI process and no `@ui5/*` API consum #### Resolution -To free disk space, remove the relevant subdirectory. - -To only remove framework downloads: +Use the dedicated cache clean command, which safely removes all cached data: ```sh -rm -rf ~/.ui5/framework/ +ui5 cache clean ``` -To only remove the build cache: +This displays the cache location, the amount of data that gets removed, and asks for confirmation before proceeding. To skip the confirmation prompt (for example in CI environments), use the `--yes` flag: ```sh -rm -rf ~/.ui5/buildCache/ +ui5 cache clean --yes ``` +The command removes the following cached data: +- **UI5 framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) +- **Build cache (Db)** — build data (`~/.ui5/buildCache/`) +- **Orphaned framework data** — incomplete framework directories left over from previously interrupted cleanup operations (`~/.ui5/.framework_to_delete_*/`) + +Any required framework dependencies will be re-downloaded during the next UI5 CLI invocation. + ::: info -If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, replace `~/.ui5/` with that path. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). +If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, the `ui5 cache clean` command will clean up that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). ::: ## Environment Variables diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js new file mode 100644 index 00000000000..91bc79f1aaa --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -0,0 +1,292 @@ +import chalk from "chalk"; +import path from "node:path"; +import process from "node:process"; +import baseMiddleware from "../middlewares/base.js"; +import {resolveUi5DataDir} from "@ui5/project/utils/dataDir"; +import * as frameworkCache from "@ui5/project/ui5Framework/cache"; +import CacheManager from "@ui5/project/build/cache/CacheManager"; + +const cacheCommand = { + command: "cache", + describe: "Manage the UI5 CLI cache (downloaded framework packages and build data)", + middlewares: [baseMiddleware], + handler: handleCache +}; + +cacheCommand.builder = function(cli) { + return cli + .demandCommand(1, "Command required. Available command is 'clean'") + .command("clean", "Remove all cached UI5 data", { + handler: handleCache, + builder: function(yargs) { + return yargs + .option("yes", { + alias: "y", + describe: "Skip the confirmation prompt, e.g. for use in CI pipelines", + default: false, + type: "boolean", + }) + .example("$0 cache clean", + "Remove all cached UI5 data after confirmation") + .example("$0 cache clean --yes", + "Remove all cached UI5 data without confirmation (e.g. in CI scenarios)") + .example("UI5_DATA_DIR=/custom/path $0 cache clean", + "Remove cached data from a non-default UI5 data directory") + .epilogue( + "The cache is stored in the UI5 data directory (default: ~/.ui5).\n" + + "Override the location with the UI5_DATA_DIR environment variable or\n" + + "the 'ui5 config set ui5DataDir' configuration option (see 'ui5 config --help').\n\n" + + "The following cache types are removed:\n" + + " UI5 framework packages: Downloaded UI5 library files " + + "(~/.ui5/framework/)\n" + + " Build cache (Db): Build data " + + "(~/.ui5/buildCache/)\n" + + " Orphaned framework data: Incomplete directories from previously interrupted cleanups\n" + + " (~/.ui5/_framework_to_delete_*/)" + ); + }, + middlewares: [baseMiddleware], + }); +}; + +const LABEL_FRAMEWORK = "UI5 Framework packages"; +const LABEL_BUILD = "Build cache (Db)"; +// Pad labels to equal width for two-column alignment +const LABEL_WIDTH = Math.max(LABEL_FRAMEWORK.length, LABEL_BUILD.length); + +/** + * Format a byte size as a human-readable string. + * + * @param {number} bytes Size in bytes + * @returns {string} Formatted size string + */ +function formatSize(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } else if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * Format framework cache stats as a human-readable detail string. + * E.g. "1,189 versions of 155 libraries" or "1 version of 1 library". + * + * @param {number} libraryCount + * @param {number} versionCount + * @returns {string} + */ +function formatFrameworkStats(libraryCount, versionCount) { + const v = `${versionCount.toLocaleString("en-US")} ${versionCount === 1 ? "version" : "versions"}`; + const l = `${libraryCount.toLocaleString("en-US")} ${libraryCount === 1 ? "library" : "libraries"}`; + return `${v} of ${l}`; +} + +/** + * Pad a label to the shared column width. + * + * @param {string} label + * @returns {string} + */ +function padLabel(label) { + return label.padEnd(LABEL_WIDTH); +} + +/** + * Display information about the cached data that will be removed, + * including the absolute paths and details about the framework and build caches, + * and any orphaned staging directories from previously interrupted clean operations. + * + * @param {object} data + * @param {object|null} data.frameworkInfo + * @param {object|null} data.buildInfo + * @param {string|null} data.frameworkAbsPath + * @param {string|null} data.buildAbsPath + * @param {number} data.buildPreSize + * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.orphanedInfo + */ +async function displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfo, +}) { + process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); + if (frameworkInfo) { + const detail = formatFrameworkStats(frameworkInfo.libraryCount, frameworkInfo.versionCount); + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkAbsPath} (${detail})\n` + ); + } + if (buildInfo) { + const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_BUILD)} ${buildAbsPath} (${detail})\n` + ); + } + if (orphanedInfo && orphanedInfo.length > 0) { + process.stderr.write( + ` ${chalk.yellow("•")} ${chalk.bold("Orphaned framework data")}` + + ` (incomplete previous clean — ` + + `${orphanedInfo.length} director${orphanedInfo.length === 1 ? "y" : "ies"})\n` + ); + for (const orphan of orphanedInfo) { + const detail = formatFrameworkStats(orphan.libraryCount, orphan.versionCount); + process.stderr.write(` ${chalk.dim(orphan.absPath)} (${detail})\n`); + } + } + process.stderr.write("\n"); +} + +/** + * Display the result of the cache cleanup operation, + * including which caches were removed and their details, + * and any orphaned staging directories that were also cleaned up. + * + * @param {object} data + * @param {object|null} data.frameworkResult + * @param {object|null} data.buildResult + * @param {string|null} data.frameworkAbsPath + * @param {string|null} data.buildAbsPath + * @param {number} data.buildPreSize + * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.orphanedInfoWithAbsPaths + */ +async function displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfoWithAbsPaths, +}) { + process.stderr.write("\n"); + if (frameworkResult && frameworkAbsPath) { + const detail = formatFrameworkStats( + frameworkResult.libraryCount, + frameworkResult.versionCount, + ); + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + + ` (${frameworkAbsPath} · ${detail})\n`, + ); + } + if (orphanedInfoWithAbsPaths && orphanedInfoWithAbsPaths.length > 0) { + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold("Orphaned framework data")}` + + ` (${orphanedInfoWithAbsPaths.length}` + + ` director${orphanedInfoWithAbsPaths.length === 1 ? "y" : "ies"})\n` + ); + for (const orphan of orphanedInfoWithAbsPaths) { + const detail = formatFrameworkStats(orphan.libraryCount, orphan.versionCount); + process.stderr.write(` ${chalk.dim(orphan.absPath)} (${detail})\n`); + } + } + if (buildResult) { + const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_BUILD)}` + + ` (${buildAbsPath}${detail ? ` · ${detail}` : ""})\n`, + ); + } + + // Success summary + const cleaned = []; + if (frameworkResult) { + cleaned.push(LABEL_FRAMEWORK); + } + if (orphanedInfoWithAbsPaths && orphanedInfoWithAbsPaths.length > 0) { + cleaned.push("Orphaned framework data"); + } + if (buildResult) { + cleaned.push(LABEL_BUILD); + } + process.stderr.write( + `\n${chalk.green("Success:")} Cleaned ${cleaned.join(" and ")}\n`, + ); +} + +/** + * Prompt the user for confirmation before proceeding with cache cleanup. + * + * @param {Yargs.Arguments} argv + * @returns {Promise} Confirmation result + */ +async function getConfirmation(argv) { + if (argv.yes) { + return true; + } + const {default: yesno} = await import("yesno"); + return yesno({ + question: "Do you want to continue? (y/N)", + defaultValue: false + }); +} + +async function handleCache(argv) { + const ui5DataDir = await resolveUi5DataDir({projectRootPath: process.cwd()}); + + process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + + const [frameworkInfo, buildInfo, orphanedInfo] = await Promise.all([ + frameworkCache.getCacheInfo(ui5DataDir), + CacheManager.getCacheInfo(ui5DataDir), + frameworkCache.getOrphanedInfo(ui5DataDir), + ]); + + if (!frameworkInfo && !buildInfo && orphanedInfo.length === 0) { + process.stderr.write("Nothing to clean\n"); + return; + } + + // Compute absolute paths once — producers return relative sub-path segments + const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; + const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; + const buildPreSize = buildInfo?.size ?? 0; + const preCleanOrphanedInfo = orphanedInfo.map( + (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) + ); + + await displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfo: preCleanOrphanedInfo, + }); + + const confirmed = await getConfirmation(argv); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } + + const [frameworkResult, buildResult] = await Promise.all([ + frameworkCache.cleanCache(ui5DataDir), + CacheManager.cleanCache(ui5DataDir), + ]); + + const [additionalFrameworkResult] = await Promise.all([ + frameworkCache.cleanAdditional(ui5DataDir), + CacheManager.cleanAdditional(ui5DataDir), + ]); + const orphanedInfoWithAbsPaths = additionalFrameworkResult.map( + (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) + ); + + await displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfoWithAbsPaths, + }); +} + +export default cacheCommand; diff --git a/packages/cli/package.json b/packages/cli/package.json index 7620da20f01..0c0982b095a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -64,7 +64,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.8.5", "update-notifier": "^7.3.1", - "yargs": "^18.0.0" + "yargs": "^18.0.0", + "yesno": "^0.4.0" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js new file mode 100644 index 00000000000..875c9cd3d99 --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,430 @@ +import test from "ava"; +import path from "node:path"; +import sinon from "sinon"; +import esmock from "esmock"; + +function getDefaultArgv() { + return { + "_": ["cache", "clean"], + "loglevel": "info", + "log-level": "info", + "logLevel": "info", + "perf": false, + "silent": false, + "$0": "ui5" + }; +} + +// Stable absolute path used as the resolved ui5DataDir in most tests +const TEST_UI5_DATA_DIR = path.resolve("test-ui5-home"); + +// Typical framework stub result shape: { path, libraryCount, versionCount } +const FRAMEWORK_STUB = {path: "framework", libraryCount: 18, versionCount: 5}; + +test.beforeEach(async (t) => { + t.context.argv = getDefaultArgv(); + t.context.stderrWriteStub = sinon.stub(process.stderr, "write"); + + // Prevent real env var from leaking into tests + delete process.env.UI5_DATA_DIR; + + t.context.resolveUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); + + t.context.frameworkCacheGetCacheInfo = sinon.stub(); + t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.frameworkCacheCleanAdditional = sinon.stub().resolves([]); + t.context.frameworkCacheGetOrphanedInfo = sinon.stub().resolves([]); + t.context.buildCacheGetCacheInfo = sinon.stub(); + t.context.buildCacheCleanCache = sinon.stub(); + + t.context.yesnoStub = sinon.stub(); + + t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { + "@ui5/project/utils/dataDir": { + resolveUi5DataDir: t.context.resolveUi5DataDirStub, + }, + "@ui5/project/ui5Framework/cache": { + getCacheInfo: t.context.frameworkCacheGetCacheInfo, + cleanCache: t.context.frameworkCacheCleanCache, + cleanAdditional: t.context.frameworkCacheCleanAdditional, + getOrphanedInfo: t.context.frameworkCacheGetOrphanedInfo, + }, + "@ui5/project/build/cache/CacheManager": { + default: class { + static getCacheInfo = t.context.buildCacheGetCacheInfo; + static cleanCache = t.context.buildCacheCleanCache; + static cleanAdditional = sinon.stub().resolves([]); + } + }, + "yesno": { + default: t.context.yesnoStub, + }, + }); +}); + +test.afterEach.always((t) => { + sinon.restore(); + esmock.purge(t.context.cache); + process.exitCode = undefined; + delete process.env.UI5_DATA_DIR; +}); + +// ─── Command structure ────────────────────────────────────────────────────── + +test("Command builder", async (t) => { + const cacheModule = await import("../../../../lib/cli/commands/cache.js"); + const cliStub = { + demandCommand: sinon.stub().returnsThis(), + command: sinon.stub().returnsThis(), + example: sinon.stub().returnsThis(), + }; + const result = cacheModule.default.builder(cliStub); + t.is(result, cliStub, "Builder returns cli instance"); + t.is(cliStub.demandCommand.callCount, 1, "demandCommand called once"); + t.is(cliStub.command.callCount, 1, "command called once"); + t.is(cliStub.example.callCount, 0, "example not called on parent command"); +}); + +test.serial("Command definition is correct", (t) => { + t.is(t.context.cache.command, "cache"); + t.is(t.context.cache.describe, + "Manage the UI5 CLI cache (downloaded framework packages and build data)"); + t.is(typeof t.context.cache.builder, "function"); + t.is(typeof t.context.cache.handler, "function"); +}); + +// ─── ui5DataDir resolution ────────────────────────────────────────────────── + +test.serial("ui5 cache clean: resolves ui5DataDir at top level", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + stderrWriteStub, resolveUi5DataDirStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(resolveUi5DataDirStub.callCount, 1, "resolveUi5DataDir called exactly once"); + t.deepEqual(resolveUi5DataDirStub.firstCall.args, [{projectRootPath: process.cwd()}], + "resolveUi5DataDir called with current project root path"); + + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, + "getCacheInfo receives the top-level resolved ui5DataDir"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Resolved ui5DataDir shown in checking line"); +}); + +test.serial("ui5 cache clean: propagates custom resolved ui5DataDir", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + resolveUi5DataDirStub} = t.context; + + const customUi5DataDir = path.resolve("custom-ui5-home"); + resolveUi5DataDirStub.resolves(customUi5DataDir); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], customUi5DataDir, + "getCacheInfo receives custom path returned from top-level resolver"); +}); + +test.serial("ui5 cache clean: propagates fallback ui5DataDir from top-level resolver", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + stderrWriteStub, resolveUi5DataDirStub} = t.context; + + const fallbackUi5DataDir = path.resolve(process.cwd(), ".ui5"); + resolveUi5DataDirStub.resolves(fallbackUi5DataDir); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], fallbackUi5DataDir, + "getCacheInfo receives default ~/.ui5 path when no configured value exists"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(fallbackUi5DataDir), "Fallback ui5DataDir shown in checking line"); +}); + +// ─── Basic flow ───────────────────────────────────────────────────────────── + +test.serial("ui5 cache clean: nothing to clean", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Checking cache at"), "Prints checking line"); + t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache not called"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called"); +}); + +test.serial("ui5 cache clean: removes both entries and reports", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); + + yesnoStub.resolves(true); + + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 7 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache called once"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache called once"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Checking cache at"), "Prints checking line"); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Shows resolved ui5DataDir"); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "framework")), "Shows absolute framework path"); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7")), "Shows absolute build path"); + t.true(allOutput.includes("5 versions of 18 libraries"), "Shows library stats format"); + t.true(allOutput.includes("8.0 MB"), "Shows pre-clean build cache size"); + t.false(allOutput.includes("7.0 MB"), "Does not show VACUUM-freed size"); + t.true(allOutput.includes("Cleaned UI5 Framework packages and Build cache (Db)"), + "Shows success summary"); +}); + +test.serial("ui5 cache clean: user cancels", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(false); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when user cancels"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when user cancels"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); + t.false(allOutput.includes("Success"), "Does not show success message"); +}); + +test.serial("ui5 cache clean: framework only — formats library stats correctly", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + let allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("5 versions of 18 libraries"), "Shows plural format"); + t.false(allOutput.includes("Build cache (Db)"), "Does not mention build cache"); + + // Singular + stderrWriteStub.resetHistory(); + const singleStub = {path: "framework", libraryCount: 1, versionCount: 1}; + frameworkCacheGetCacheInfo.resetBehavior(); + frameworkCacheCleanCache.resetBehavior(); + frameworkCacheGetCacheInfo.resolves(singleStub); + frameworkCacheCleanCache.resolves(singleStub); + + argv["yes"] = true; + await cache.handler(argv); + + allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1 version of 1 library"), "Uses singular 'version' and 'library'"); +}); + +test.serial("ui5 cache clean: thousands separator in library stats", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, yesnoStub} = t.context; + + const largeStub = {path: "framework", libraryCount: 155, versionCount: 1189}; + frameworkCacheGetCacheInfo.resolves(largeStub); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves(largeStub); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1,189 versions of 155 libraries"), + "Shows thousands separator for large counts"); +}); + +test.serial("ui5 cache clean: build only", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.false(allOutput.includes("UI5 Framework packages"), "Does not mention framework"); + t.true(allOutput.includes("50.0 KB"), "Shows build cache size"); + t.true(allOutput.includes("Cleaned Build cache (Db)"), "Success mentions build cache only"); +}); + +test.serial("ui5 cache clean: formats byte sizes correctly (< 1 KB)", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 500}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 500}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("500 B"), "Shows bytes format for size < 1 KB"); +}); + +test.serial("ui5 cache clean: formats KB sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("50.0 KB"), "Shows KB format"); +}); + +test.serial("ui5 cache clean: formats GB sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("2.5 GB"), "Shows GB format"); +}); + +test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --yes"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache called"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache called"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Success"), "Shows success message"); +}); + +test.serial("ui5 cache clean: shows orphaned framework data in pre-confirmation summary", async (t) => { + const {cache, argv, stderrWriteStub, yesnoStub, + frameworkCacheCleanCache, frameworkCacheGetOrphanedInfo} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: "_framework_to_delete_abcd", libraryCount: 5, versionCount: 2}, + ]); + frameworkCacheCleanCache.resolves(null); + + yesnoStub.resolves(true); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Orphaned framework data"), "Shows orphaned section in pre-confirm summary"); + t.true(allOutput.includes("incomplete previous clean"), "Shows orphaned context message"); + t.true(allOutput.includes("1 directory"), "Shows singular 'directory' for one orphan"); + t.true(allOutput.includes("_framework_to_delete_abcd"), "Shows orphaned dir path"); + t.true(allOutput.includes("2 versions of 5 libraries"), "Shows orphaned dir stats"); +}); + +test.serial("ui5 cache clean: shows orphaned framework data in post-clean summary", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheGetOrphanedInfo, + frameworkCacheCleanCache, frameworkCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves({path: "framework", libraryCount: 3, versionCount: 1}); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: "_framework_to_delete_ab12", libraryCount: 3, versionCount: 1}, + {path: "_framework_to_delete_cd34", libraryCount: 3, versionCount: 1}, + ]); + frameworkCacheCleanCache.resolves({path: "framework", libraryCount: 3, versionCount: 1}); + frameworkCacheCleanAdditional.resolves([ + {path: "_framework_to_delete_ab12", libraryCount: 3, versionCount: 1}, + {path: "_framework_to_delete_cd34", libraryCount: 3, versionCount: 1}, + ]); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Removed Orphaned framework data"), "Shows orphaned section in result"); + t.true(allOutput.includes("2 directories"), "Shows plural 'directories' for multiple orphans"); + t.true(allOutput.includes("_framework_to_delete_ab12"), "Shows first orphaned dir path"); + t.true(allOutput.includes("_framework_to_delete_cd34"), "Shows second orphaned dir path"); +}); + +test.serial("ui5 cache clean: shows orphaned-only success summary when no active framework", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheGetOrphanedInfo, + frameworkCacheCleanCache, frameworkCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: "_framework_to_delete_zz99", libraryCount: 10, versionCount: 3}, + ]); + frameworkCacheCleanCache.resolves(null); + frameworkCacheCleanAdditional.resolves([ + {path: "_framework_to_delete_zz99", libraryCount: 10, versionCount: 3}, + ]); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Orphaned framework data"), "Shows orphaned section"); + t.true(allOutput.includes("Cleaned Orphaned framework data"), "Success summary mentions orphaned data"); + t.false(allOutput.includes("UI5 Framework packages"), "Does not mention main framework when absent"); +}); diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index fc91a486888..3489afb9253 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -511,6 +511,46 @@ export default class BuildCacheStorage { return new Set(rows.map((row) => row.integrity)); } + /** + * Clears all records from all tables and runs VACUUM. + * Returns the number of bytes freed. + * + * @returns {number} Number of bytes freed + */ + clearAllRecords() { + const bytesBefore = this.getDatabaseSize(); + + this.#db.exec("BEGIN"); + this.#db.exec("DELETE FROM content"); + this.#db.exec("DELETE FROM index_cache"); + this.#db.exec("DELETE FROM stage_metadata"); + this.#db.exec("DELETE FROM task_metadata"); + this.#db.exec("DELETE FROM result_metadata"); + this.#db.exec("COMMIT"); + this.#db.exec("VACUUM"); + + const bytesAfter = this.getDatabaseSize(); + + return bytesBefore - bytesAfter; + } + + /** + * Checks if the database has any records in any table. + * + * @returns {boolean} True if there are any records + */ + hasRecords() { + const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; + for (const table of tables) { + const {is_populated: isPopulated} = + this.#db.prepare(`SELECT EXISTS(SELECT 1 FROM ${table} LIMIT 1) as is_populated`).get(); + if (isPopulated) { + return true; + } + } + return false; + } + /** * Closes the database connection */ @@ -525,4 +565,15 @@ export default class BuildCacheStorage { this.#db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); this.#db.close(); } + + /** + * Get the total size of the database file + * + * @returns {number} Database size in bytes + */ + getDatabaseSize() { + const pageCount = this.#db.prepare("PRAGMA page_count").get().page_count; + const pageSize = this.#db.prepare("PRAGMA page_size").get().page_size; + return pageCount * pageSize; + } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index e1e37a6f521..371cfb98fe3 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,6 +1,7 @@ import path from "node:path"; import os from "node:os"; import Configuration from "../../config/Configuration.js"; +import {access} from "node:fs/promises"; import {getLogger} from "@ui5/logger"; import BuildCacheStorage from "./BuildCacheStorage.js"; @@ -337,4 +338,95 @@ export default class CacheManager { cacheManagerInstances.delete(this.#cacheDir); } } + + /** + * Checks if the cache database exists and is accessible for the given directory. + * + * @param {string} dbDir Path to DB + * @returns {Promise} True if the cache database exists and is accessible + */ + static async #isCacheDbAvailable(dbDir) { + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return false; + } + return true; + } + + /** + * Get build cache info for the current version. + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number}|null>} Build cache info or null + */ + static async getCacheInfo(ui5DataDir) { + const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); + const isAvailable = await CacheManager.#isCacheDbAvailable(dbDir); + if (!isAvailable) { + return null; + } + + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const size = storage.getDatabaseSize(); + return { + path: `buildCache/${CACHE_VERSION}`, + size, + }; + } + } finally { + storage.close(); + } + return null; + } + + /** + * Clean build cache by clearing all records from SQLite database for the current version. + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number}|null>} Removal result or null + */ + static async cleanCache(ui5DataDir) { + const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); + const isAvailable = await CacheManager.#isCacheDbAvailable(dbDir); + if (!isAvailable) { + return null; + } + + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const freedSize = storage.clearAllRecords(); + return { + path: `buildCache/${CACHE_VERSION}`, + size: freedSize, + }; + } + } finally { + storage.close(); + } + return null; + } + + /** + * Clean additional build cache resources that are safe to remove independently. + * + * Note: This method is a placeholder for interface compatibility across + * cleanup tasks and currently does not perform any cleanup. + * + * @public + * @static + * @param {string} _ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise} Always resolves with an empty array + */ + static async cleanAdditional(_ui5DataDir) { + return []; + } } diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js new file mode 100644 index 00000000000..e1399889a98 --- /dev/null +++ b/packages/project/lib/ui5Framework/cache.js @@ -0,0 +1,210 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import {getRandomValues} from "node:crypto"; + +/** + * Utilities for cleaning the UI5 framework cache. + * + * @public + * @module @ui5/project/ui5Framework/cache + */ + +const FRAMEWORK_DIR_NAME = "framework"; + +/** + * Prefix used for staging directories created during an atomic framework cache clean. + * The directory is renamed to this prefix + a random hex suffix before deletion so that + * the original path is immediately removed and the deletion can proceed outside the rename. + */ +const STAGING_DIR_PREFIX = "_framework_to_delete_"; + +/** + * Count unique libraries and versions in the packages/ subdirectory. + * + * Library names are deduplicated globally: sap.m under @openui5 and @sapui5 counts + * as one library. + * + * @param {string} frameworkDir Absolute path to the framework directory + * @returns {Promise<{libraries: number, versions: number}|null>} + * Null if the directory does not exist or contains no installed libraries. + */ +async function getPackageStats(frameworkDir) { + try { + await fs.access(frameworkDir); + } catch { + return null; + } + + const packagesDir = path.join(frameworkDir, "packages"); + let projectDirs; + try { + projectDirs = await fs.readdir(packagesDir, {withFileTypes: true}); + } catch { + return null; + } + + const extractSubDir = (dirList) => { + return dirList.filter((e) => e.isDirectory()) + .map((currentDir) => { + try { + return fs.readdir(path.join(currentDir.parentPath, currentDir.name), {withFileTypes: true}); + } catch { + return; + } + }); + }; + + const libDirs = (await Promise.all(extractSubDir(projectDirs))).filter(Boolean).flat(); + const versionDirs = (await Promise.all(extractSubDir(libDirs))).filter(Boolean).flat(); + + const librarySet = new Set(libDirs.map((e) => e.name)); + const versionSet = new Set(versionDirs.map((e) => e.name)); + + return librarySet.size > 0 ? + {libraries: librarySet.size, versions: versionSet.size} : + null; +} + +/** + * Get framework cache info. + * + * @public + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} + * Framework cache info, or null if no packages are installed. + */ +export async function getCacheInfo(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + const stats = await getPackageStats(frameworkDir); + if (!stats) { + return null; + } + return { + path: FRAMEWORK_DIR_NAME, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; +} + +/** + * Scans ui5DataDir for orphaned staging directories left behind by previously + * interrupted clean operations (i.e. process killed after rename but before deletion). + * Returns stats per orphan without deleting anything. + * + * @public + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} + */ +export async function getOrphanedInfo(ui5DataDir) { + let entries; + try { + entries = await fs.readdir(ui5DataDir, {withFileTypes: true}); + } catch { + return []; + } + + const orphans = entries.filter( + (e) => e.isDirectory() && e.name.startsWith(STAGING_DIR_PREFIX) + ); + + if (orphans.length === 0) { + return []; + } + + const results = await Promise.all(orphans.map(async (orphan) => { + const orphanDir = path.join(ui5DataDir, orphan.name); + const stats = await getPackageStats(orphanDir); + if (!stats) { + return null; + } + return { + path: orphan.name, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; + })); + + return results.filter(Boolean); +} + +/** + * Scans ui5DataDir for orphaned staging directories left behind by previously + * interrupted clean operations (i.e. process killed after rename but before deletion). + * + * Returns an array of result objects — one per orphaned directory found — each + * containing the path, library count and version count so the caller can include + * them in the cleanup summary. + * + * Deletion failures are swallowed per entry so one stuck directory does not prevent + * the others from being removed. + * + * @public + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} + */ +export async function cleanAdditional(ui5DataDir) { + const orphans = await getOrphanedInfo(ui5DataDir); + + for (const orphan of orphans) { + const orphanDir = path.join(ui5DataDir, orphan.path); + try { + await fs.rm(orphanDir, {recursive: true, force: true}); + } catch { + // Ignore deletion errors + } + } + + return orphans; +} + +/** + * Clean the framework cache directory. + * + * Returns null if no framework packages are installed. + * Otherwise uses an atomic rename to make the framework directory disappear in a single + * filesystem operation: + * + * 1. Clear cacache's in-process memoization (no path needed — global operation). + * 2. Atomically rename framework/ to a staging dir. + * After this point the original path no longer exists. + * 3. Delete the staging dir recursively. Its contents are now fully private + * to this operation. + * + * @public + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} + * Removal result, or null if no framework packages were installed. + */ +export async function cleanCache(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + const stats = await getPackageStats(frameworkDir); + if (!stats) { + return null; + } + + // Clear cacache's in-process memoization before the rename. + // clearMemoized() operates globally (no path argument) and is synchronous. + try { + const {clearMemoized} = await import("cacache"); + clearMemoized(); + } catch { + // cacache not available — no-op + } + + // Atomically rename framework/ to a staging directory. + // fs.rename is a single syscall and completes in microseconds. + // After this line the original path no longer exists. + const stagingDir = path.join( + ui5DataDir, + `${STAGING_DIR_PREFIX}${Buffer.from(getRandomValues(new Uint8Array(2))).toString("hex")}` + ); + await fs.rename(frameworkDir, stagingDir); + + await fs.rm(stagingDir, {recursive: true, force: true}); + + return { + path: FRAMEWORK_DIR_NAME, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; +} diff --git a/packages/project/lib/utils/dataDir.js b/packages/project/lib/utils/dataDir.js new file mode 100644 index 00000000000..2171d657380 --- /dev/null +++ b/packages/project/lib/utils/dataDir.js @@ -0,0 +1,39 @@ +import path from "node:path"; +import os from "node:os"; +import Configuration from "../config/Configuration.js"; + +/** + * Utilities for resolving the UI5 data directory. + * + * @public + * @module @ui5/project/utils/dataDir + */ + +/** + * Resolves the UI5 data directory using the standard precedence chain: + *
    + *
  1. UI5_DATA_DIR environment variable
  2. + *
  3. ui5DataDir option from the configuration file (~/.ui5rc)
  4. + *
  5. Default: ~/.ui5
  6. + *
+ * + * This function always returns an absolute path and never undefined. + * + * @public + * @param {object} [options] + * @param {string} [options.projectRootPath] Root directory of the processed project. + * Relative values from environment variable or configuration are resolved against this path. + * Defaults to process.cwd() when not provided. + * @returns {Promise} Resolved absolute path to the UI5 data directory + */ +export async function resolveUi5DataDir({projectRootPath} = {}) { + let ui5DataDir = process.env.UI5_DATA_DIR; + if (!ui5DataDir) { + const config = await Configuration.fromFile(); + ui5DataDir = config.getUi5DataDir(); + } + if (ui5DataDir) { + return path.resolve(projectRootPath ?? process.cwd(), ui5DataDir); + } + return path.resolve(os.homedir(), ".ui5"); +} diff --git a/packages/project/package.json b/packages/project/package.json index 17b2e8957c4..5fd38574fa0 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -20,12 +20,15 @@ "exports": { "./config/Configuration": "./lib/config/Configuration.js", "./build/cache/Cache": "./lib/build/cache/Cache.js", + "./build/cache/CacheManager": "./lib/build/cache/CacheManager.js", "./specifications/Specification": "./lib/specifications/Specification.js", "./specifications/SpecificationVersion": "./lib/specifications/SpecificationVersion.js", "./ui5Framework/Sapui5MavenSnapshotResolver": "./lib/ui5Framework/Sapui5MavenSnapshotResolver.js", "./ui5Framework/Openui5Resolver": "./lib/ui5Framework/Openui5Resolver.js", "./ui5Framework/Sapui5Resolver": "./lib/ui5Framework/Sapui5Resolver.js", "./ui5Framework/maven/SnapshotCache": "./lib/ui5Framework/maven/SnapshotCache.js", + "./ui5Framework/cache": "./lib/ui5Framework/cache.js", + "./utils/dataDir": "./lib/utils/dataDir.js", "./validation/validator": "./lib/validation/validator.js", "./validation/ValidationError": "./lib/validation/ValidationError.js", "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 684e8634a84..cfce00dcdf5 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -13,19 +13,22 @@ test("export of package.json", (t) => { // Check number of definied exports test("check number of exports", (t) => { const packageJson = require("@ui5/project/package.json"); - t.is(Object.keys(packageJson.exports).length, 14); + t.is(Object.keys(packageJson.exports).length, 17); }); // Public API contract (exported modules) [ "config/Configuration", "build/cache/Cache", + "build/cache/CacheManager", "specifications/Specification", "specifications/SpecificationVersion", "ui5Framework/Openui5Resolver", "ui5Framework/Sapui5Resolver", "ui5Framework/Sapui5MavenSnapshotResolver", "ui5Framework/maven/SnapshotCache", + "ui5Framework/cache", + "utils/dataDir", "validation/validator", "validation/ValidationError", "graph/ProjectGraph", diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js new file mode 100644 index 00000000000..a8189b75119 --- /dev/null +++ b/packages/project/test/lib/ui5framework/cache.js @@ -0,0 +1,231 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import sinon from "sinon"; +import esmock from "esmock"; +import {getCacheInfo, cleanCache, cleanAdditional} from "../../../lib/ui5Framework/cache.js"; + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "ui5framework-cache"); + +test.beforeEach(async (t) => { + const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(testDir, {recursive: true}); + t.context.testDir = testDir; +}); + +test.afterEach.always(async (t) => { + await fs.rm(t.context.testDir, {recursive: true, force: true}); + sinon.restore(); +}); + + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function mkPackage(testDir, project, library, version) { + const dir = path.join(testDir, "framework", "packages", project, library, version); + await fs.mkdir(dir, {recursive: true}); + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({name: `${project}/${library}`, version})); +} + +async function mkPackageIn(baseDir, project, library, version) { + const dir = path.join(baseDir, "packages", project, library, version); + await fs.mkdir(dir, {recursive: true}); + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({name: `${project}/${library}`, version})); +} + +// ─── getCacheInfo ───────────────────────────────────────────────────────────── + +test("getCacheInfo: non-existent framework directory returns null", async (t) => { + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: framework dir exists but no packages/ subdir returns null", async (t) => { + await fs.mkdir(path.join(t.context.testDir, "framework", "cacache"), {recursive: true}); + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: packages/ exists but is empty returns null", async (t) => { + await fs.mkdir(path.join(t.context.testDir, "framework", "packages"), {recursive: true}); + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: counts libraries and versions", async (t) => { + // 2 unique library names across 2 scopes, 3 unique versions + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.148.0"); + await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.libraryCount, 2); // sap.m counted once (deduplicated across scopes) + t.is(result.versionCount, 3); // 1.120.0, 1.148.0, 1.38.1 +}); + +test("getCacheInfo: deduplicates versions across libraries", async (t) => { + // Both libraries have 1.120.0 — version should count once + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.libraryCount, 2); + t.is(result.versionCount, 1); // 1.120.0 deduplicated +}); + +test("getCacheInfo: single library and version", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.libraryCount, 1); + t.is(result.versionCount, 1); +}); + +// ─── cleanCache ─────────────────────────────────────────────────────────────── + +test("cleanCache: returns null for non-existent framework directory", async (t) => { + const result = await cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: returns null when packages/ has no installed libraries", async (t) => { + await fs.mkdir(path.join(t.context.testDir, "framework", "packages"), {recursive: true}); + const result = await cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: renames then removes framework directory and returns stats", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.148.0"); + + const frameworkDir = path.join(t.context.testDir, "framework"); + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.libraryCount, 2); + t.is(result.versionCount, 2); // 1.120.0, 1.148.0 + + // framework/ is gone — getCacheInfo returns null + t.is(await getCacheInfo(t.context.testDir), null); + + // No staging dirs remain after a successful clean + const entries = await fs.readdir(t.context.testDir); + t.false(entries.some((e) => e.startsWith("_framework_to_delete_")), + "no staging dirs remain after successful clean"); + + // packages/ is gone + await t.throwsAsync(fs.access(path.join(frameworkDir, "packages"))); +}); + +test("cleanCache: removes directory with multiple scopes", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); + + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.is(result.libraryCount, 1); // sap.m deduplicated + t.is(result.versionCount, 2); + + t.is(await getCacheInfo(t.context.testDir), null); +}); + +test("cleanCache: does not include orphaned field in result", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.false(Object.prototype.hasOwnProperty.call(result, "orphaned"), + "cleanCache result does not include orphaned — use cleanAdditional for that"); +}); + +test("cleanCache: does not remove orphaned staging dirs — that is cleanAdditional's job", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const orphanDir = path.join(t.context.testDir, "_framework_to_delete_abcd"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.100.0"); + + await cleanCache(t.context.testDir); + + // Orphan is still present after cleanCache — cleanAdditional handles it + await t.notThrowsAsync(fs.access(orphanDir), "orphaned dir is not touched by cleanCache"); +}); + +// ─── cleanAdditional ────────────────────────────────────────────────────────── + +test("cleanAdditional: returns empty array when no orphaned staging dirs exist", async (t) => { + const result = await cleanAdditional(t.context.testDir); + t.deepEqual(result, []); +}); + +test("cleanAdditional: detects and removes orphaned staging dirs, reports them", async (t) => { + const orphanDir = path.join(t.context.testDir, "_framework_to_delete_abcd"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.100.0"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.110.0"); + + const result = await cleanAdditional(t.context.testDir); + + t.is(result.length, 1, "one orphaned dir reported"); + const orphanResult = result[0]; + t.true(orphanResult.path.startsWith("_framework_to_delete_"), "orphan path has staging prefix"); + t.is(orphanResult.libraryCount, 1); + t.is(orphanResult.versionCount, 2); + + await t.throwsAsync(fs.access(orphanDir), {code: "ENOENT"}, "orphaned staging dir removed"); +}); + +test("cleanAdditional: removes multiple orphaned staging dirs and reports each", async (t) => { + const orphan1 = path.join(t.context.testDir, "_framework_to_delete_1111"); + const orphan2 = path.join(t.context.testDir, "_framework_to_delete_2222"); + + await mkPackageIn(orphan1, "@openui5", "sap.m", "1.90.0"); + await mkPackageIn(orphan2, "@openui5", "sap.ui.core", "1.91.0"); + await mkPackageIn(orphan2, "@openui5", "sap.ui.core", "1.92.0"); + + const result = await cleanAdditional(t.context.testDir); + + t.is(result.length, 2, "two orphaned dirs reported"); + + const sorted = [...result].sort((a, b) => a.path.localeCompare(b.path)); + t.is(sorted[0].libraryCount, 1); + t.is(sorted[0].versionCount, 1); + t.is(sorted[1].libraryCount, 1); + t.is(sorted[1].versionCount, 2); + + await t.throwsAsync(fs.access(orphan1), {code: "ENOENT"}); + await t.throwsAsync(fs.access(orphan2), {code: "ENOENT"}); +}); + +test("cleanAdditional: orphaned dir deletion failure is non-fatal", async (t) => { + const orphanDir = path.join(t.context.testDir, "_framework_to_delete_fail"); + await mkPackageIn(orphanDir, "@openui5", "sap.m", "1.80.0"); + + const rmStub = sinon.stub().callsFake(async (p, opts) => { + if (p === orphanDir) { + throw new Error("simulated deletion failure"); + } + return fs.rm(p, opts); + }); + + const {cleanAdditional: cleanAdditionalMocked} = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, rm: rmStub}} + ); + + try { + const result = await t.notThrowsAsync(cleanAdditionalMocked(t.context.testDir)); + t.truthy(result, "cleanAdditional completes despite orphan deletion failure"); + } finally { + esmock.purge(cleanAdditionalMocked); + await fs.rm(orphanDir, {recursive: true, force: true}).catch(() => {}); + } +}); + diff --git a/packages/project/test/lib/utils/dataDir.js b/packages/project/test/lib/utils/dataDir.js new file mode 100644 index 00000000000..ebaf8f81c25 --- /dev/null +++ b/packages/project/test/lib/utils/dataDir.js @@ -0,0 +1,82 @@ +import test from "ava"; +import path from "node:path"; +import os from "node:os"; +import sinon from "sinon"; +import esmock from "esmock"; + +test.beforeEach(async (t) => { + t.context.originalUi5DataDirEnv = process.env.UI5_DATA_DIR; + delete process.env.UI5_DATA_DIR; + + t.context.configGetUi5DataDirStub = sinon.stub().returns(undefined); + t.context.ConfigurationStub = { + fromFile: sinon.stub().resolves({ + getUi5DataDir: t.context.configGetUi5DataDirStub + }) + }; + + const {resolveUi5DataDir} = await esmock("../../../lib/utils/dataDir.js", { + "../../../lib/config/Configuration.js": t.context.ConfigurationStub + }); + t.context.resolveUi5DataDir = resolveUi5DataDir; +}); + +test.afterEach.always((t) => { + if (typeof t.context.originalUi5DataDirEnv === "undefined") { + delete process.env.UI5_DATA_DIR; + } else { + process.env.UI5_DATA_DIR = t.context.originalUi5DataDirEnv; + } + sinon.restore(); +}); + +test.serial("resolveUi5DataDir: returns ~/.ui5 when nothing is configured", async (t) => { + const {resolveUi5DataDir} = t.context; + const result = await resolveUi5DataDir(); + t.is(result, path.resolve(os.homedir(), ".ui5")); + t.true(path.isAbsolute(result), "Default path is absolute"); +}); + +test.serial("resolveUi5DataDir: env var takes precedence over configuration", async (t) => { + const {resolveUi5DataDir} = t.context; + process.env.UI5_DATA_DIR = path.resolve("env", "data", "dir"); + t.context.configGetUi5DataDirStub.returns(path.resolve("config", "data", "dir")); + + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("env", "data", "dir")); + t.is(t.context.ConfigurationStub.fromFile.callCount, 0, "Configuration not read when env var is set"); +}); + +test.serial("resolveUi5DataDir: resolves relative env var against projectRootPath", async (t) => { + const {resolveUi5DataDir} = t.context; + process.env.UI5_DATA_DIR = "relative/env-data"; + const projectRootPath = path.resolve("my", "project"); + + const result = await resolveUi5DataDir({projectRootPath}); + t.is(result, path.join(projectRootPath, "relative/env-data")); +}); + +test.serial("resolveUi5DataDir: returns absolute config value", async (t) => { + const {resolveUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns(path.resolve("config", "data", "dir")); + + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("config", "data", "dir")); +}); + +test.serial("resolveUi5DataDir: resolves relative config value against projectRootPath", async (t) => { + const {resolveUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns("relative/config-data"); + const projectRootPath = path.resolve("my", "project"); + + const result = await resolveUi5DataDir({projectRootPath}); + t.is(result, path.join(projectRootPath, "relative/config-data")); +}); + +test.serial("resolveUi5DataDir: resolves relative config value against process.cwd by default", async (t) => { + const {resolveUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns("relative/config-data"); + + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("relative/config-data")); +});