diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 7fe8c4486a3..424a72149b1 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -20,28 +20,43 @@ UI5 CLI stores several kinds of data under your user's home directory in `~/.ui5 | `~/.ui5/buildCache/` | Build cache used by `ui5 build` and `ui5 serve` (see [Build Cache Control](./Builder.md#build-cache-control)) | Yes — rebuilt on next `ui5 build` / `ui5 serve` | | `~/.ui5/server/` | Locally generated SSL certificate and private key for HTTPS / HTTP/2 mode | Yes — regenerated on next HTTPS server start; the new certificate must be re-trusted | -::: warning -Only remove these directories when no UI5 CLI process and no `@ui5/*` API consumer is actively running. Deleting files that are in use can cause running builds or servers to fail or produce inconsistent results. -::: - #### Resolution -To free disk space, remove the relevant subdirectory. +Use the dedicated cache clean command, which removes all cached data: + +```sh +ui5 cache clean +``` -To only remove framework downloads: +For a detailed preview and grouped cleanup summary, use the `--verbose` flag: ```sh -rm -rf ~/.ui5/framework/ +ui5 cache clean --verbose ``` -To only remove the build cache: +To skip the confirmation prompt (for example in CI environments), use the `--force` flag. +In non-verbose mode with `--force`, the command runs completely silent: ```sh -rm -rf ~/.ui5/buildCache/ +ui5 cache clean --force ``` +The command removes the following cached data: +- **UI5 Framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) +- **Build cache** — build data (`~/.ui5/buildCache/`) + +If a previous `ui5 cache clean` was interrupted (e.g. process killed or system crash), the command also detects and removes any leftover data from that interrupted operation, listed as separate entries: +- **Stale UI5 Framework packages** — incomplete framework directories left over from a previously interrupted cleanup (`~/.ui5/_framework_to_delete_*/`) +- **Stale build cache** — freed database pages not yet reclaimed during a previously interrupted cleanup + +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). +::: + +::: warning +Only remove these directories, or run `ui5 cache clean`, when no UI5 CLI process and no `@ui5/*` API consumer is actively running. Running `ui5 cache clean` while `ui5 build` or `ui5 serve` is in progress can break the running process and lead to failed or inconsistent results. ::: ## Environment Variables diff --git a/package-lock.json b/package-lock.json index 0759bc1e9ef..af3f161a293 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1262,20 +1262,30 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -2671,9 +2681,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2691,9 +2698,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2711,9 +2715,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2731,9 +2732,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2751,9 +2749,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2771,9 +2766,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2791,9 +2783,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2811,9 +2800,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2859,6 +2845,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz", @@ -3026,9 +3035,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3043,9 +3049,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3060,9 +3063,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3077,9 +3077,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3094,9 +3091,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3111,9 +3105,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3128,9 +3119,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3145,9 +3133,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3370,9 +3355,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3393,9 +3375,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3416,9 +3395,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3439,9 +3415,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3462,9 +3435,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3485,9 +3455,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3720,9 +3687,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3736,9 +3700,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3752,9 +3713,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3768,9 +3726,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3784,9 +3739,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3800,9 +3752,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3816,9 +3765,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3832,9 +3778,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3848,9 +3791,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3864,9 +3804,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3880,9 +3817,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3896,9 +3830,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3912,9 +3843,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4378,9 +4306,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4397,9 +4322,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4416,9 +4338,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4435,9 +4354,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4697,6 +4613,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4714,6 +4631,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4731,6 +4649,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4748,6 +4667,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4765,6 +4685,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4782,6 +4703,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4799,6 +4721,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4816,6 +4739,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4833,6 +4757,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4850,6 +4775,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4867,6 +4793,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4884,6 +4811,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4901,6 +4829,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4918,6 +4847,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4935,6 +4865,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4952,6 +4883,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4969,6 +4901,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4986,6 +4919,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5003,6 +4937,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5020,6 +4955,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -12173,9 +12109,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12196,9 +12129,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12219,9 +12149,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12242,9 +12169,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -18783,7 +18707,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" }, "bin": { "ui5": "bin/ui5.cjs" diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js new file mode 100644 index 00000000000..5df7d88285a --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -0,0 +1,182 @@ +import chalk from "chalk"; +import path from "node:path"; +import os from "node:os"; +import process from "node:process"; +import {isLogLevelEnabled} from "@ui5/logger"; +import baseMiddleware from "../middlewares/base.js"; +import Configuration from "@ui5/project/config/Configuration"; +import FrameworkCache from "@ui5/project/internal/ui5Framework/cache"; +import CacheManager from "@ui5/project/internal/build/cache/CacheManager"; +import { + CACHE_CLEAN_HELP_USAGE, + displayCacheCleanWarning, + displayCacheInfo, + displayCleanupResult, +} from "./helpers/cacheOutput.js"; + +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 + .usage(CACHE_CLEAN_HELP_USAGE) + .option("force", { + alias: "f", + 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 --force", + "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"); + }, + middlewares: [baseMiddleware], + }); +}; +/** + * Prompt the user for confirmation before proceeding with cache cleanup. + * + * @param {Yargs.Arguments} argv + * @returns {Promise} Confirmation result + */ +async function getConfirmation(argv) { + if (argv.force) { + return true; + } + displayCacheCleanWarning(); + const {default: yesno} = await import("yesno"); + return yesno({ + question: "Proceed with cache cleanup? (y/N)", + defaultValue: false + }); +} + +async function resolveCacheUi5DataDir() { + // TODO: Consolidate ui5DataDir resolution once PR #1456 follow-up cleanup is done. + // Keep behavior aligned with existing main-branch resolution order. + let ui5DataDir = process.env.UI5_DATA_DIR; + if (!ui5DataDir) { + const config = await Configuration.fromFile(); + ui5DataDir = config.getUi5DataDir(); + } + if (ui5DataDir) { + return path.resolve(process.cwd(), ui5DataDir); + } + return path.join(os.homedir(), ".ui5"); +} + +function withAbsPath(entries, ui5DataDir) { + return entries.map((entry) => { + return {...entry, absPath: getAbsPath(ui5DataDir, entry)}; + }); +} + +function getAbsPath(ui5DataDir, cacheEntry) { + if (!cacheEntry?.path) { + return null; + } + return path.join(ui5DataDir, cacheEntry.path); +} + +async function handleCache(argv) { + const ui5DataDir = await resolveCacheUi5DataDir(); + const isVerbose = isLogLevelEnabled("verbose"); + + if (isVerbose) { + // logger.verbose pollutes output with framework noise. + process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + } + + const [frameworkInfo, buildInfo] = await Promise.all([ + FrameworkCache.getCacheInfo(ui5DataDir), + CacheManager.getCacheInfo(ui5DataDir), + ]); + + const hasActiveCache = Boolean(frameworkInfo || buildInfo); + let staleInfo = []; + let buildStaleInfo = []; + + if (isVerbose || !hasActiveCache) { + [staleInfo, buildStaleInfo] = await Promise.all([ + FrameworkCache.getAdditionalCacheInfo(ui5DataDir), + CacheManager.getAdditionalCacheInfo(ui5DataDir), + ]); + } + + const hasStaleCache = staleInfo.length > 0 || buildStaleInfo.length > 0; + + if (!hasActiveCache && !hasStaleCache) { + if (isVerbose) { + process.stderr.write(`${chalk.italic("Nothing to clean")}\n`); + } + return; + } + + if (isVerbose) { + await displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath: getAbsPath(ui5DataDir, frameworkInfo), + buildAbsPath: getAbsPath(ui5DataDir, buildInfo), + buildPreSize: buildInfo?.size ?? 0, + staleInfo: withAbsPath(staleInfo, ui5DataDir), + buildAdditionalInfo: withAbsPath(buildStaleInfo, ui5DataDir), + }); + } + + const confirmed = await getConfirmation(argv); + if (!confirmed) { + if (isVerbose) { + process.stderr.write(`${chalk.italic("Cancelled")}\n`); + } + return; + } + + if (isVerbose) { + // Get fresh build stale info to distinguish + // between active and stale build cache after cleanup. + buildStaleInfo = await CacheManager.getAdditionalCacheInfo(ui5DataDir); + } + + const [frameworkCleanupResult, buildCleanupResult] = await Promise.all([ + FrameworkCache.cleanCache(ui5DataDir), + CacheManager.cleanCache(ui5DataDir), + ]); + + const [additionalFrameworkCleanupResult, buildStaleCleanupResult] = await Promise.all([ + FrameworkCache.cleanAdditional(ui5DataDir), + CacheManager.cleanAdditional(ui5DataDir), + ]); + + if (isVerbose) { + const staleBuildCleanupResult = buildStaleInfo?.length > 0 ? + buildStaleCleanupResult : []; + const cleanedStaleFramework = withAbsPath(additionalFrameworkCleanupResult, ui5DataDir); + const cleanedStaleBuild = withAbsPath(staleBuildCleanupResult, ui5DataDir); + const frameworkResultAbsPath = getAbsPath(ui5DataDir, frameworkCleanupResult); + const buildResultAbsPath = getAbsPath(ui5DataDir, buildCleanupResult); + await displayCleanupResult({ + frameworkResult: frameworkCleanupResult, + buildResult: buildCleanupResult, + frameworkAbsPath: frameworkResultAbsPath, + buildAbsPath: buildResultAbsPath, + buildSize: buildCleanupResult?.size ?? 0, + staleInfoWithAbsPaths: cleanedStaleFramework, + buildAdditionalResult: cleanedStaleBuild, + }); + } +} + +export default cacheCommand; diff --git a/packages/cli/lib/cli/commands/helpers/cacheOutput.js b/packages/cli/lib/cli/commands/helpers/cacheOutput.js new file mode 100644 index 00000000000..b6a2544be1d --- /dev/null +++ b/packages/cli/lib/cli/commands/helpers/cacheOutput.js @@ -0,0 +1,245 @@ +import chalk from "chalk"; +import process from "node:process"; + +const GROUP_FRAMEWORK = "Framework"; +const GROUP_BUILD = "Build"; +const SECTION_ACTIVE_CACHE = "Active Cache"; +const SECTION_STALE_CACHE = "Stale Cache"; + +const CACHE_CLEAN_WARNING = + "Only run ui5 cache clean when no UI5 CLI process and no @ui5/* API consumer is actively running."; +const CACHE_CLEAN_WARNING_IMPACT = + "Running ui5 cache clean while ui5 build or ui5 serve is in progress can break the running process " + + "and lead to failed or inconsistent results."; +const PARALLEL_CLEANUP_NOTICE = "Nothing left to clean. A parallel cleanup might have happened."; + +export const CACHE_CLEAN_HELP_USAGE = + `WARNING: ${CACHE_CLEAN_WARNING}\n${CACHE_CLEAN_WARNING_IMPACT}\n\nUsage: ui5 cache clean [options]`; + +const PREVIEW_MARKER = chalk.yellow("•"); +const SUCCESS_MARKER = chalk.green("✓"); +const ITEM_DIVIDER = chalk.dim("·"); + +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`; +} + +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}`; +} + +function writeSectionHeader(title) { + process.stderr.write(` ${chalk.bold.cyan(title)}\n`); +} + +function writeCategoryHeader(title) { + process.stderr.write(` ${chalk.bold(title)}\n`); +} + +function writePreviewItem(absPath, detail) { + process.stderr.write( + ` ${PREVIEW_MARKER} ${chalk.dim(absPath)}` + + `${detail ? ` ${ITEM_DIVIDER} ${detail}` : ""}\n` + ); +} + +function writeCleanupItem(absPath, detail) { + process.stderr.write( + ` ${SUCCESS_MARKER} Removed ${chalk.dim(absPath)}` + + `${detail ? ` ${ITEM_DIVIDER} ${detail}` : ""}\n` + ); +} + +function writeGroupedSections(sections, itemWriter) { + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (i > 0) { + process.stderr.write("\n"); + } + writeSectionHeader(section.title); + for (let j = 0; j < section.categories.length; j++) { + const category = section.categories[j]; + writeCategoryHeader(category.title); + for (const item of category.items) { + itemWriter(item); + } + } + } +} + +export function displayCacheCleanWarning() { + process.stderr.write(`${chalk.bold.yellow("Warning:")} ${chalk.italic(CACHE_CLEAN_WARNING)}\n`); + process.stderr.write(`${chalk.italic(CACHE_CLEAN_WARNING_IMPACT)}\n\n`); +} + +function createFrameworkItems(entries) { + const items = []; + for (const entry of entries) { + const detail = formatFrameworkStats(entry.libraryCount, entry.versionCount); + items.push({absPath: entry.absPath, detail}); + } + return items; +} + +function createBuildItems(entries, detailFormatter) { + const items = []; + for (const entry of entries) { + const detail = detailFormatter(entry.size); + items.push({absPath: entry.absPath, detail}); + } + return items; +} + +function createSections({ + activeFramework, + activeBuild, + staleFrameworkEntries, + staleBuildEntries, + staleBuildDetailFormatter, +}) { + const sections = []; + + const activeCategories = []; + if (activeFramework) { + const detail = formatFrameworkStats(activeFramework.libraryCount, activeFramework.versionCount); + activeCategories.push({ + title: GROUP_FRAMEWORK, + items: [{absPath: activeFramework.absPath, detail}], + }); + } + if (activeBuild) { + const detail = activeBuild.size > 0 ? formatSize(activeBuild.size) : ""; + activeCategories.push({ + title: GROUP_BUILD, + items: [{absPath: activeBuild.absPath, detail}], + }); + } + if (activeCategories.length > 0) { + sections.push({title: SECTION_ACTIVE_CACHE, categories: activeCategories}); + } + + const staleCategories = []; + const staleFrameworkItems = createFrameworkItems(staleFrameworkEntries); + if (staleFrameworkItems.length > 0) { + staleCategories.push({title: GROUP_FRAMEWORK, items: staleFrameworkItems}); + } + const staleBuildItems = createBuildItems(staleBuildEntries, staleBuildDetailFormatter); + if (staleBuildItems.length > 0) { + staleCategories.push({title: GROUP_BUILD, items: staleBuildItems}); + } + if (staleCategories.length > 0) { + sections.push({title: SECTION_STALE_CACHE, categories: staleCategories}); + } + + return sections; +} + +/** + * Display information about the cached data that will be removed. + * Entries are grouped by active and stale cache data. + * + * @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.staleInfo + * @param {Array<{absPath: string, size: number}>} data.buildAdditionalInfo + */ +export function displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + staleInfo, + buildAdditionalInfo, +}) { + const sections = createSections({ + activeFramework: frameworkInfo ? { + absPath: frameworkAbsPath, + libraryCount: frameworkInfo.libraryCount, + versionCount: frameworkInfo.versionCount, + } : null, + activeBuild: buildInfo ? { + absPath: buildAbsPath, + size: buildPreSize, + } : null, + staleFrameworkEntries: staleInfo, + staleBuildEntries: buildAdditionalInfo, + staleBuildDetailFormatter: (size) => size > 0 ? formatSize(size) : "", + }); + + process.stderr.write(`\n${chalk.bold("The following cached data will be removed:")}\n`); + process.stderr.write("\n"); + writeGroupedSections(sections, ({absPath, detail}) => { + writePreviewItem(absPath, detail); + }); + process.stderr.write("\n"); +} + +/** + * Display the result of the cache cleanup operation, grouped by active and stale cache. + * + * @param {object} data + * @param {{libraryCount: number, versionCount: number}|null} data.frameworkResult + * @param {object|null} data.buildResult + * @param {string|null} data.frameworkAbsPath + * @param {string|null} data.buildAbsPath + * @param {number} data.buildSize + * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.staleInfoWithAbsPaths + * @param {Array<{absPath: string, size: number}>} data.buildAdditionalResult + */ +export function displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildSize, + staleInfoWithAbsPaths, + buildAdditionalResult, +}) { + const sections = createSections({ + activeFramework: frameworkResult && frameworkAbsPath ? { + absPath: frameworkAbsPath, + libraryCount: frameworkResult.libraryCount, + versionCount: frameworkResult.versionCount, + } : null, + activeBuild: buildResult && buildAbsPath ? { + absPath: buildAbsPath, + size: buildSize, + } : null, + staleFrameworkEntries: staleInfoWithAbsPaths, + staleBuildEntries: buildAdditionalResult, + staleBuildDetailFormatter: (size) => size > 0 ? `freed ${formatSize(size)}` : "", + }); + + if (sections.length === 0) { + process.stderr.write(`${chalk.italic(PARALLEL_CLEANUP_NOTICE)}\n`); + return; + } + + process.stderr.write(`\n${chalk.bold("Cleanup result:")}\n`); + + process.stderr.write("\n"); + writeGroupedSections(sections, ({absPath, detail}) => { + writeCleanupItem(absPath, detail); + }); + + const cleanedSections = sections.map((section) => { + const categories = section.categories.map((category) => category.title).join(" and "); + return `${section.title} (${categories})`; + }); + + process.stderr.write(`\n${chalk.green("Success:")} Cleaned ${cleanedSections.join(" and ")}\n`); +} 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..3ac2d0c6e57 --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,880 @@ +import test from "ava"; +import path from "node:path"; +import os from "node:os"; +import sinon from "sinon"; +import esmock from "esmock"; +import {setLogLevel} from "@ui5/logger"; + +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}; +const WARNING_PREFIX = "Warning:"; +const WARNING_TEXT = + "Only run ui5 cache clean when no UI5 CLI process and no @ui5/* API consumer is actively running."; +const WARNING_IMPACT_TEXT = + "Running ui5 cache clean while ui5 build or ui5 serve is in progress can break the running process " + + "and lead to failed or inconsistent results."; +const PARALLEL_CLEANUP_NOTICE = "Nothing left to clean. A parallel cleanup might have happened."; +const ACTIVE_CACHE_HEADER = "Active Cache"; +const STALE_CACHE_HEADER = "Stale Cache"; + +test.beforeEach(async (t) => { + setLogLevel("info"); + 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.configurationGetUi5DataDirStub = sinon.stub().returns(TEST_UI5_DATA_DIR); + t.context.configurationFromFileStub = sinon.stub().resolves({ + getUi5DataDir: t.context.configurationGetUi5DataDirStub, + }); + + t.context.frameworkCacheGetCacheInfo = sinon.stub(); + t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.frameworkCacheCleanAdditional = sinon.stub().resolves([]); + t.context.frameworkCacheGetAdditionalCacheInfo = sinon.stub().resolves([]); + t.context.buildCacheGetCacheInfo = sinon.stub(); + t.context.buildCacheCleanCache = sinon.stub(); + t.context.buildCacheCleanAdditional = sinon.stub().resolves([]); + t.context.buildCacheGetAdditionalCacheInfo = sinon.stub().resolves([]); + + t.context.yesnoStub = sinon.stub(); + + t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { + "@ui5/project/config/Configuration": { + default: { + fromFile: t.context.configurationFromFileStub, + }, + }, + "@ui5/project/internal/ui5Framework/cache": { + default: class { + static getCacheInfo = t.context.frameworkCacheGetCacheInfo; + static cleanCache = t.context.frameworkCacheCleanCache; + static cleanAdditional = t.context.frameworkCacheCleanAdditional; + static getAdditionalCacheInfo = t.context.frameworkCacheGetAdditionalCacheInfo; + } + }, + "@ui5/project/internal/build/cache/CacheManager": { + default: class { + static getCacheInfo = t.context.buildCacheGetCacheInfo; + static cleanCache = t.context.buildCacheCleanCache; + static cleanAdditional = t.context.buildCacheCleanAdditional; + static getAdditionalCacheInfo = t.context.buildCacheGetAdditionalCacheInfo; + } + }, + "yesno": { + default: t.context.yesnoStub, + }, + }); +}); + +test.afterEach.always((t) => { + setLogLevel("info"); + 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 yargsStub = { + usage: sinon.stub().returnsThis(), + option: sinon.stub().returnsThis(), + example: sinon.stub().returnsThis(), + }; + const cliStub = { + demandCommand: sinon.stub().returnsThis(), + command: sinon.stub().callsFake((_name, _desc, config) => { + // Invoke the sub-command builder to cover the inner yargs setup + if (config?.builder) { + config.builder(yargsStub); + } + return cliStub; + }), + }; + 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(yargsStub.usage.callCount, 1, "usage called once for warning help banner"); + t.true(yargsStub.usage.firstCall.args[0].startsWith("WARNING:"), + "usage banner starts with warning"); + t.is(yargsStub.option.callCount, 1, "option called for --force flag"); + t.is(yargsStub.example.callCount, 3, "example called 3 times"); +}); + +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: uses resolved path from configuration", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + stderrWriteStub, configurationFromFileStub, configurationGetUi5DataDirStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(configurationFromFileStub.callCount, 1, "Configuration.fromFile called exactly once"); + t.is(configurationGetUi5DataDirStub.callCount, 1, "Configuration#getUi5DataDir called exactly once"); + + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, + "getCacheInfo receives the path returned by configuration"); + + 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: prefers UI5_DATA_DIR env var over configuration", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + configurationFromFileStub} = t.context; + + const envUi5DataDir = path.resolve("env-ui5-home"); + process.env.UI5_DATA_DIR = envUi5DataDir; + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(configurationFromFileStub.callCount, 0, + "Configuration.fromFile must not be called when UI5_DATA_DIR is set"); + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], envUi5DataDir, + "getCacheInfo receives value from UI5_DATA_DIR"); +}); + +test.serial("ui5 cache clean: falls back to ~/.ui5 when configuration has no value", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + stderrWriteStub, configurationGetUi5DataDirStub} = t.context; + + const fallbackUi5DataDir = path.join(os.homedir(), ".ui5"); + configurationGetUi5DataDirStub.returns(undefined); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + 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"]; + setLogLevel("verbose"); + 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.false(allOutput.includes(ACTIVE_CACHE_HEADER), "Does not show Active Cache group when nothing can be cleaned"); + t.false(allOutput.includes(STALE_CACHE_HEADER), "Does not show Stale Cache group when nothing can be cleaned"); + 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"]; + setLogLevel("verbose"); + 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(WARNING_PREFIX), "Shows safety warning before interactive confirmation"); + t.true(allOutput.includes(WARNING_TEXT), "Shows safety warning details"); + t.true(allOutput.includes(WARNING_IMPACT_TEXT), "Shows warning impact details"); + 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("Stale Cache"), "Does not report stale cache section when only active cache existed"); + t.true(allOutput.includes("Cleaned Active Cache (Framework and Build)"), + "Shows success summary"); + const warningCall = stderrWriteStub.getCalls().find((call) => { + return call.args[0].includes(WARNING_PREFIX); + }); + t.truthy(warningCall, "Warning line is written to stderr"); + t.true(warningCall.callId < yesnoStub.firstCall.callId, + "Warning is displayed before the confirmation prompt is shown"); +}); + +test.serial("ui5 cache clean: cleanup result uses fresh active cache paths after confirmation", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 2 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.false(allOutput.includes("Removed null"), "Does not print null cache path in cleanup result"); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7")), + "Shows absolute build cache path in cleanup result when build cache appears after confirmation"); + t.true(allOutput.includes("Cleaned Active Cache (Framework and Build)"), + "Success summary includes both active framework and build cache"); +}); + +test.serial("ui5 cache clean: reports parallel cleanup in verbose mode", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves(null); + buildCacheCleanCache.resolves(null); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("The following cached data will be removed:"), + "Keeps pre-confirmation preview output"); + t.true(allOutput.includes(PARALLEL_CLEANUP_NOTICE), + "Reports that cleanup was already performed in parallel"); + t.false(allOutput.includes("Cleanup result:"), "Does not print cleanup result table for no-op cleanup"); + t.false(allOutput.includes("Success:"), "Does not print success summary for no-op cleanup"); +}); + +test.serial("ui5 cache clean: non-verbose mode suppresses detailed summaries", 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); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(WARNING_PREFIX), "Shows warning in non-verbose mode before confirmation"); + t.true(allOutput.includes(WARNING_IMPACT_TEXT), "Shows warning impact in non-verbose mode"); + t.false(allOutput.includes("Checking cache at"), "Does not show checking line in non-verbose mode"); + t.false(allOutput.includes("The following cached data will be removed:"), + "Does not show pre-clean detailed section without --verbose"); + t.false(allOutput.includes("Cleanup result:"), + "Does not show post-clean detailed section without --verbose"); + t.false(allOutput.includes("Success:"), "Does not show success message in non-verbose mode"); + t.false(allOutput.includes("Cancelled"), "Does not show cancelled message in non-verbose mode"); +}); + +test.serial("ui5 cache clean: does not report parallel cleanup in non-verbose mode", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves(null); + buildCacheCleanCache.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(WARNING_PREFIX), + "Shows warning in non-verbose mode before confirmation"); + t.true(allOutput.includes(WARNING_IMPACT_TEXT), + "Shows warning impact in non-verbose mode before confirmation"); + t.false(allOutput.includes(PARALLEL_CLEANUP_NOTICE), + "Does not print parallel cleanup notice in non-verbose mode"); + t.false(allOutput.includes("Cleanup result:"), "Does not print cleanup result table for no-op cleanup"); + t.false(allOutput.includes("Success:"), "Does not print success summary for no-op cleanup"); +}); + +test.serial("ui5 cache clean: non-verbose mode with active cache skips additional info lookup", async (t) => { + const {cache, argv, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub, + frameworkCacheGetAdditionalCacheInfo, buildCacheGetAdditionalCacheInfo} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 7 * 1024 * 1024}); + yesnoStub.resolves(true); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheGetAdditionalCacheInfo.callCount, 0, + "Does not fetch additional framework cache info in non-verbose mode when active cache exists"); + t.is(buildCacheGetAdditionalCacheInfo.callCount, 0, + "Does not fetch additional build cache info in non-verbose mode when active cache exists"); +}); + +test.serial("ui5 cache clean: non-verbose mode with stale cache only stays quiet except warning/prompt", async (t) => { + const {cache, argv, stderrWriteStub, yesnoStub, + frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + frameworkCacheGetAdditionalCacheInfo, buildCacheGetAdditionalCacheInfo, + frameworkCacheCleanAdditional, buildCacheCleanAdditional, + frameworkCacheCleanCache, buildCacheCleanCache} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetAdditionalCacheInfo.resolves([ + {path: "_framework_to_delete_abcd", libraryCount: 5, versionCount: 2}, + ]); + buildCacheGetAdditionalCacheInfo.resolves([]); + frameworkCacheCleanCache.resolves(null); + buildCacheCleanCache.resolves(null); + frameworkCacheCleanAdditional.resolves([ + {path: "_framework_to_delete_abcd", libraryCount: 5, versionCount: 2}, + ]); + buildCacheCleanAdditional.resolves([]); + yesnoStub.resolves(true); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 1, "Prompts for confirmation in non-verbose mode when stale cache is found"); + t.is(frameworkCacheGetAdditionalCacheInfo.callCount, 1, + "Fetches stale framework info when no active cache exists"); + t.is(buildCacheGetAdditionalCacheInfo.callCount, 1, + "Fetches stale build info when no active cache exists"); + t.is(frameworkCacheCleanCache.callCount, 1, "Still executes framework cache cleanup flow"); + t.is(buildCacheCleanCache.callCount, 1, "Still executes build cache cleanup flow"); + t.is(frameworkCacheCleanAdditional.callCount, 1, "Cleans stale framework cache entries"); + t.is(buildCacheCleanAdditional.callCount, 1, "Cleans stale build cache entries"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(WARNING_PREFIX), "Shows warning in non-verbose mode"); + t.true(allOutput.includes(WARNING_IMPACT_TEXT), "Shows warning impact in non-verbose mode"); + t.false(allOutput.includes("Checking cache at"), "Does not show checking line in non-verbose mode"); + t.false(allOutput.includes("The following cached data will be removed:"), + "Does not show detailed pre-clean summary in non-verbose mode"); + t.false(allOutput.includes("Cleanup result:"), + "Does not show detailed cleanup summary in non-verbose mode"); + t.false(allOutput.includes("Success:"), "Does not show success message in non-verbose mode"); + t.false(allOutput.includes("Cancelled"), "Does not show cancelled message in non-verbose mode"); +}); + +test.serial("ui5 cache clean: non-verbose --force mode is completely silent", 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}); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 7 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["force"] = true; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 0, "Does not prompt for confirmation when --force is used"); + t.is(stderrWriteStub.callCount, 0, "Does not write any output in non-verbose --force mode"); +}); + +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(WARNING_PREFIX), "Shows warning before confirmation"); + t.false(allOutput.includes("Cancelled"), "Does not show cancelled message in non-verbose mode"); + 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"]; + setLogLevel("verbose"); + 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"), "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["force"] = 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"]; + setLogLevel("verbose"); + 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"]; + setLogLevel("verbose"); + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.false(allOutput.includes("Framework cache"), "Does not mention framework"); + t.true(allOutput.includes("50.0 KB"), "Shows build cache size"); + t.true(allOutput.includes("Cleaned Active Cache (Build)"), "Success mentions active build group"); +}); + +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"]; + setLogLevel("verbose"); + 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"]; + setLogLevel("verbose"); + 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"]; + setLogLevel("verbose"); + argv["force"] = 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 --force: 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"]; + setLogLevel("verbose"); + argv["force"] = true; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --force"); + 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"); + t.false(allOutput.includes(WARNING_PREFIX), "Does not show warning when --force is used"); +}); + +test.serial("ui5 cache clean: shows stale framework data in pre-confirmation summary", async (t) => { + const {cache, argv, stderrWriteStub, yesnoStub, + frameworkCacheCleanCache, frameworkCacheGetAdditionalCacheInfo} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetAdditionalCacheInfo.resolves([ + {path: "_framework_to_delete_abcd", libraryCount: 5, versionCount: 2}, + ]); + frameworkCacheCleanCache.resolves(null); + + yesnoStub.resolves(true); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Stale Cache"), "Shows stale cache group in pre-confirm summary"); + t.true(allOutput.includes("Framework"), "Shows framework subgroup in pre-confirm summary"); + t.true(allOutput.includes("_framework_to_delete_abcd"), "Shows stale removal dir path indented"); + t.true(allOutput.includes("2 versions of 5 libraries"), "Shows stale removal dir stats"); +}); + +test.serial("ui5 cache clean: shows stale framework data in post-clean summary", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheGetAdditionalCacheInfo, + frameworkCacheCleanCache, frameworkCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves({path: "framework", libraryCount: 3, versionCount: 1}); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetAdditionalCacheInfo.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"]; + setLogLevel("verbose"); + argv["force"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Cleanup result:"), "Shows cleanup result heading"); + t.true(allOutput.includes("Stale Cache"), "Shows stale cache group in result"); + t.true(allOutput.includes("Framework"), "Shows framework subgroup in result"); + t.true(allOutput.includes("_framework_to_delete_ab12"), "Shows first stale removal dir path indented"); + t.true(allOutput.includes("_framework_to_delete_cd34"), "Shows second stale removal dir path indented"); + const summaryLine = allOutput.split("\n").find((line) => line.includes("Success:")); + t.truthy(summaryLine, "Output includes success summary line"); + t.true(summaryLine.includes("Active Cache (Framework) and Stale Cache (Framework)"), + "Summary line distinguishes active and stale framework groups"); + t.false(summaryLine.includes("Stale Cache (Framework and Framework)"), + "Summary line does not duplicate framework subgroup within stale section"); +}); + +test.serial("ui5 cache clean: shows stale-only success summary when no active framework", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheGetAdditionalCacheInfo, + frameworkCacheCleanCache, frameworkCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetAdditionalCacheInfo.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"]; + setLogLevel("verbose"); + argv["force"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Stale Cache"), "Shows stale cache group"); + t.true(allOutput.includes("Framework"), "Shows framework subgroup"); + t.true(allOutput.includes("Cleaned Stale Cache (Framework)"), + "Success summary mentions stale framework group"); + t.false(allOutput.includes("Removed UI5 Framework packages"), + "Does not show main framework removed line when absent"); +}); + +test.serial("ui5 cache clean: shows stale build cache in pre-confirm and post-clean summary", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetAdditionalCacheInfo, + buildCacheCleanCache, buildCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + buildCacheGetAdditionalCacheInfo.resolves([ + {path: "buildCache/v0_7", size: 40 * 1024 * 1024}, + ]); + buildCacheCleanCache.resolves(null); + buildCacheCleanAdditional.resolves([ + {path: "buildCache/v0_7", size: 40 * 1024 * 1024}, + ]); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + argv["force"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Stale Cache"), "Shows stale cache group"); + t.true(allOutput.includes("Build"), "Shows build subgroup"); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7")), + "Shows stale build cache path indented"); + t.true(allOutput.includes("Removed"), "Post-clean result shows removed entries"); + t.true(allOutput.includes("freed 40.0 MB"), "Shows freed size in post-clean result"); + t.true(allOutput.includes("Cleaned Stale Cache (Build)"), "Success summary mentions stale build group"); +}); + +test.serial("ui5 cache clean: post-clean summary does not duplicate active build cleanup as stale", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetAdditionalCacheInfo, + buildCacheGetCacheInfo, buildCacheCleanCache, buildCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 30 * 1024 * 1024}); + buildCacheGetAdditionalCacheInfo.resolves([]); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 30 * 1024 * 1024}); + buildCacheCleanAdditional.resolves([ + {path: "buildCache/v0_7", size: 30 * 1024 * 1024}, + ]); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + argv["force"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("30.0 MB"), "Shows active build cleanup size in post-clean output"); + t.true(allOutput.includes("Cleaned Active Cache (Build)"), + "Success summary reports active build cleanup"); + t.false(allOutput.includes("Stale Cache"), + "Does not duplicate active build cleanup as stale build cleanup"); + t.false(allOutput.includes("freed 30.0 MB"), + "Does not render stale build cleanup details when active build cleanup already covered it"); +}); + +test.serial("ui5 cache clean: keeps stale build cleanup when stale existed pre-confirm", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetAdditionalCacheInfo, + buildCacheGetCacheInfo, buildCacheCleanCache, buildCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 30 * 1024 * 1024}); + buildCacheGetAdditionalCacheInfo.resolves([ + {path: "buildCache/v0_7", size: 12 * 1024 * 1024}, + ]); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 30 * 1024 * 1024}); + buildCacheCleanAdditional.resolves([ + {path: "buildCache/v0_7", size: 30 * 1024 * 1024}, + ]); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + argv["force"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Cleaned Active Cache (Build) and Stale Cache (Build)"), + "Keeps stale build section when stale build existed before confirmation"); + t.true(allOutput.includes("freed 30.0 MB"), + "Shows stale build cleanup details when stale build existed before confirmation"); +}); + +test.serial("ui5 cache clean: post-clean summary ignores stale preview when cleanup result is empty", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetAdditionalCacheInfo, + buildCacheGetCacheInfo, buildCacheCleanCache, buildCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + buildCacheGetAdditionalCacheInfo.resolves([ + {path: "buildCache/v0_7", size: 12 * 1024 * 1024}, + ]); + buildCacheCleanCache.resolves(null); + buildCacheCleanAdditional.resolves([]); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + argv["force"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7")), + "Pre-confirm summary still shows stale build preview entry"); + t.true(allOutput.includes(PARALLEL_CLEANUP_NOTICE), + "Post-clean summary reflects current cleanup state, not stale preview snapshot"); + t.false(allOutput.includes("Cleaned Stale Cache (Build)"), + "Does not claim stale build cleanup without current cleanup result"); +}); + +test.serial("ui5 cache clean: build cache and stale build cache with size 0 omit size detail", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetAdditionalCacheInfo, + buildCacheCleanCache, buildCacheCleanAdditional, buildCacheGetCacheInfo} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 0}); + buildCacheGetAdditionalCacheInfo.resolves([ + {path: "buildCache/v0_7", size: 0}, + ]); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 0}); + buildCacheCleanAdditional.resolves([ + {path: "buildCache/v0_7", size: 0}, + ]); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + argv["force"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.false(allOutput.includes("0 B"), "Does not show zero size"); + t.true(allOutput.includes("Build"), "Shows build subgroup"); + t.true(allOutput.includes("Removed"), "Shows removed result lines"); + t.false(allOutput.includes("freed"), "Does not show freed label when size is 0"); +}); + +test.serial("ui5 cache clean: pre-clean summary shows only Active Cache group", async (t) => { + const {cache, argv, stderrWriteStub, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + t.context.buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 2 * 1024 * 1024}); + t.context.frameworkCacheGetAdditionalCacheInfo.resolves([]); + t.context.buildCacheGetAdditionalCacheInfo.resolves([]); + yesnoStub.resolves(false); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(ACTIVE_CACHE_HEADER), "Shows Active Cache group header"); + t.false(allOutput.includes(STALE_CACHE_HEADER), + "Does not show Stale Cache group header when no stale entries exist"); +}); + +test.serial("ui5 cache clean: pre-clean summary shows only Stale Cache group", async (t) => { + const {cache, argv, stderrWriteStub, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + t.context.frameworkCacheGetAdditionalCacheInfo.resolves([ + {path: "_framework_to_delete_abcd", libraryCount: 4, versionCount: 2}, + ]); + t.context.buildCacheGetAdditionalCacheInfo.resolves([ + {path: "buildCache/v0_7", size: 5 * 1024 * 1024}, + ]); + yesnoStub.resolves(false); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.false(allOutput.includes(ACTIVE_CACHE_HEADER), + "Does not show Active Cache group header when no active entries exist"); + t.true(allOutput.includes(STALE_CACHE_HEADER), "Shows Stale Cache group header"); +}); + +test.serial("ui5 cache clean: pre-clean summary shows both groups when active and stale entries exist", async (t) => { + const {cache, argv, stderrWriteStub, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + t.context.buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 6 * 1024 * 1024}); + t.context.frameworkCacheGetAdditionalCacheInfo.resolves([ + {path: "_framework_to_delete_xy12", libraryCount: 3, versionCount: 1}, + ]); + t.context.buildCacheGetAdditionalCacheInfo.resolves([ + {path: "buildCache/v0_6", size: 1 * 1024 * 1024}, + ]); + yesnoStub.resolves(false); + + argv["_"] = ["cache", "clean"]; + setLogLevel("verbose"); + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(ACTIVE_CACHE_HEADER), "Shows Active Cache group header"); + t.true(allOutput.includes(STALE_CACHE_HEADER), "Shows Stale Cache group header"); +}); diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index fc91a486888..13fb50c491a 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -9,6 +9,9 @@ const log = getLogger("build:cache:BuildCacheStorage"); const METADATA_COMPRESSION_THRESHOLD = 4096; const CONTENT_COMPRESSION_THRESHOLD = 128; +/** All live data table names */ +const DATA_TABLES = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; + /** * Unified SQLite-backed storage for the build cache * @@ -83,6 +86,11 @@ export default class BuildCacheStorage { data BLOB NOT NULL, PRIMARY KEY (project_id, build_signature, stage_signature) ) WITHOUT ROWID; + + CREATE TABLE IF NOT EXISTS _vacuum_pending ( + pending INTEGER NOT NULL DEFAULT 0 + ); + INSERT OR IGNORE INTO _vacuum_pending(rowid, pending) VALUES(1, 0); `); } @@ -511,6 +519,86 @@ export default class BuildCacheStorage { return new Set(rows.map((row) => row.integrity)); } + /** + * Checks if the database has any records in any table. + * + * @returns {boolean} True if there are any records + */ + hasRecords() { + for (const table of DATA_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; + } + + /** + * Atomically drops all live tables and recreates fresh empty ones in a single + * transaction. The operation completes in milliseconds regardless of data volume — + * DROP TABLE never reads row data; it only removes the schema entry and adds + * pages to the freelist. Call {@link vacuum} afterwards to reclaim disk space. + * + * A persistent marker is set so that a deferred VACUUM can be detected on the + * next invocation even if the process exits before {@link vacuum} runs. + * + * @returns {number} Database size in bytes before the drop (pending reclamation after vacuum) + */ + dropAllRecords() { + const bytesBefore = this.getDatabaseSize(); + + this.#db.exec("BEGIN"); + try { + for (const table of DATA_TABLES) { + this.#db.exec(`DROP TABLE ${table}`); + } + this.#createTables(); + this.#db.exec("UPDATE _vacuum_pending SET pending = 1 WHERE rowid = 1"); + this.#db.exec("COMMIT"); + } catch (err) { + this.#db.exec("ROLLBACK"); + // "no such table" would only occur if a concurrent process dropped the tables + // between our BEGIN and our first DROP — an edge case that WAL's writer + // serialization makes nearly impossible, but guard for a clear error message. + if (/** @type {NodeJS.ErrnoException} */ (err).message?.includes("no such table")) { + throw new Error( + "Build cache clean was already performed by another process. " + + "Run ui5 cache clean again to complete the deferred VACUUM.", + {cause: err} + ); + } + throw err; + } + + return bytesBefore; + } + + /** + * Returns true if a VACUUM is pending — i.e. {@link dropAllRecords} was called + * but {@link vacuum} has not yet run to reclaim the freed disk space. + * + * @returns {boolean} + */ + hasVacuumPending() { + return this.#db.prepare("SELECT pending FROM _vacuum_pending WHERE rowid = 1").get().pending === 1; + } + + /** + * Runs VACUUM to reclaim disk space from freed pages and clears the pending marker. + * This is the slow half of the two-phase cache clean; call it from + * cleanAdditional after the fast {@link dropAllRecords} pass. + * + * @returns {number} Number of bytes freed + */ + vacuum() { + const bytesBefore = this.getDatabaseSize(); + this.#db.exec("VACUUM"); + this.#db.exec("UPDATE _vacuum_pending SET pending = 0 WHERE rowid = 1"); + return bytesBefore - this.getDatabaseSize(); + } + /** * Closes the database connection */ @@ -525,4 +613,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..3bdb1fb5cce 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"; @@ -31,6 +32,8 @@ const CACHE_VERSION = "v0_7"; * - Configurable cache location via UI5_DATA_DIR or configuration * - SQLite-backed storage for fast read/write operations * + * @ignore Do not expose this class in the public API documentation. + * It is only used internally by the CLI. * @class */ export default class CacheManager { @@ -337,4 +340,113 @@ 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; + } + + /** + * Opens a BuildCacheStorage for the versioned build cache directory. + * + * @param {string} ui5DataDir + * @param {*} defaultValue Value to return when the database is not available + * @param {Function} fn Operation to run against the open storage + * @returns {Promise<*>} + */ + static async #withStorage(ui5DataDir, defaultValue, fn) { + const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); + if (!await CacheManager.#isCacheDbAvailable(dbDir)) { + return defaultValue; + } + const storage = new BuildCacheStorage(dbDir); + try { + return fn(storage); + } finally { + storage.close(); + } + } + + /** + * 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 getCacheInfo(ui5DataDir) { + return CacheManager.#withStorage(ui5DataDir, null, (storage) => { + if (!storage.hasRecords()) { + return null; + } + return {path: `buildCache/${CACHE_VERSION}`, size: storage.getDatabaseSize()}; + }); + } + + /** + * Clean build cache by atomically dropping all live tables and recreating fresh + * empty ones. The drop is O(1) and completes in milliseconds. + * Actual disk reclamation (VACUUM) is deferred to {@link cleanAdditional}. + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number}|null>} Removal result (size = bytes pending reclamation) or null + */ + static cleanCache(ui5DataDir) { + return CacheManager.#withStorage(ui5DataDir, null, (storage) => { + if (!storage.hasRecords()) { + return null; + } + return {path: `buildCache/${CACHE_VERSION}`, size: storage.dropAllRecords()}; + }); + } + + /** + * Runs VACUUM to reclaim disk space from a previous {@link cleanCache} call. + * Only runs if the database has freelist pages (i.e. cleanup was deferred). + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} Cleaned entries, or empty array if nothing to reclaim + */ + static cleanAdditional(ui5DataDir) { + return CacheManager.#withStorage(ui5DataDir, [], (storage) => { + if (!storage.hasVacuumPending()) { + return []; + } + return [{path: `buildCache/${CACHE_VERSION}`, size: storage.vacuum()}]; + }); + } + + /** + * Returns info about pending disk reclamation — i.e. a previous {@link cleanCache} + * whose VACUUM has not yet been run by {@link cleanAdditional}. + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} Pending entries, or empty array if none + */ + static getAdditionalCacheInfo(ui5DataDir) { + return CacheManager.#withStorage(ui5DataDir, [], (storage) => { + if (!storage.hasVacuumPending()) { + return []; + } + return [{path: `buildCache/${CACHE_VERSION}`, size: storage.getDatabaseSize()}]; + }); + } } diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js new file mode 100644 index 00000000000..48ffc19005f --- /dev/null +++ b/packages/project/lib/ui5Framework/cache.js @@ -0,0 +1,237 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import {getRandomValues} from "node:crypto"; + + +const FRAMEWORK_DIR_NAME = "framework"; + +/** + * Prefix used for 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 PENDING_REMOVAL_DIR_PREFIX = "_framework_to_delete_"; + +/** + * Provides static utilities for inspecting and cleaning the UI5 framework cache. + * + * @ignore Do not expose this class in the public API documentation. + * It is only used internally by the CLI. + * @class + */ +export default class FrameworkCache { + /** + * 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. + */ + static async 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, + }; + } + + /** + * Get additional framework cache info. + * + * Scans ui5DataDir for stale removal directories left behind by previously + * interrupted clean operations (i.e. process killed after rename but before deletion). + * Returns stats per stale directory without deleting anything. + * + * @public + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} + */ + static async getAdditionalCacheInfo(ui5DataDir) { + let entries; + try { + entries = await fs.readdir(ui5DataDir, {withFileTypes: true}); + } catch { + return []; + } + + const staleDirs = entries.filter( + (e) => e.isDirectory() && e.name.startsWith(PENDING_REMOVAL_DIR_PREFIX) + ); + + if (staleDirs.length === 0) { + return []; + } + + const results = await Promise.all(staleDirs.map(async (staleDir) => { + const staleDirPath = path.join(ui5DataDir, staleDir.name); + const stats = await getPackageStats(staleDirPath); + if (!stats) { + return null; + } + return { + path: staleDir.name, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; + })); + + return results.filter(Boolean); + } + + /** + * Scans ui5DataDir for stale removal 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 stale 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. Only entries that were removed successfully are + * returned. + * + * @public + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} + */ + static async cleanAdditional(ui5DataDir) { + const staleDirs = await FrameworkCache.getAdditionalCacheInfo(ui5DataDir); + const removedStaleDirs = []; + + for (const staleDir of staleDirs) { + const staleDirPath = path.join(ui5DataDir, staleDir.path); + try { + await fs.rm(staleDirPath, {recursive: true, force: true}); + removedStaleDirs.push(staleDir); + } catch { + // Ignore deletion errors + } + } + + return removedStaleDirs; + } + + /** + * Clean the framework cache directory. + * + * Returns null if no framework packages are installed, or if the + * directory was concurrently removed by another process during the operation. + * 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 temporary removal dir. + * After this point the original path no longer exists. + * If ENOENT is raised (concurrent deletion), returns null. + * 3. Delete the temporary removal 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 or the directory + * was concurrently removed. + */ + static async 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 temporary removal directory. + // fs.rename is a single syscall and completes in microseconds. + // After this line the original path no longer exists. + const removalDir = path.join( + ui5DataDir, + `${PENDING_REMOVAL_DIR_PREFIX}${Buffer.from(getRandomValues(new Uint8Array(2))).toString("hex")}` + ); + try { + await fs.rename(frameworkDir, removalDir); + } catch (err) { + if (/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT") { + // Directory was removed by another process after our check — already clean. + return null; + } + throw err; + } + + await fs.rm(removalDir, {recursive: true, force: true}); + + return { + path: FRAMEWORK_DIR_NAME, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; + } +} + +/** + * 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; + } + + /** + * Reads direct subdirectories for each given directory entry and flattens the result. + * Any unreadable subdirectory is skipped. + * + * @param {Array} dirList + * @returns {Promise>} + */ + async function readSubDirectories(dirList) { + const directoryEntries = dirList.filter((entry) => entry.isDirectory()); + const nestedEntries = await Promise.all(directoryEntries.map((currentDir) => { + return fs.readdir( + path.join(currentDir.parentPath, currentDir.name), + {withFileTypes: true} + ).catch(() => undefined); + })); + return nestedEntries.filter(Boolean).flat(); + } + + const libDirs = await readSubDirectories(projectDirs); + const versionDirs = await readSubDirectories(libDirs); + + 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; +} + diff --git a/packages/project/package.json b/packages/project/package.json index 17b2e8957c4..725f41ae49c 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -18,6 +18,8 @@ ], "type": "module", "exports": { + "./internal/build/cache/CacheManager": "./lib/build/cache/CacheManager.js", + "./internal/ui5Framework/cache": "./lib/ui5Framework/cache.js", "./config/Configuration": "./lib/config/Configuration.js", "./build/cache/Cache": "./lib/build/cache/Cache.js", "./specifications/Specification": "./lib/specifications/Specification.js", diff --git a/packages/project/test/lib/build/cache/BuildCacheStorage.js b/packages/project/test/lib/build/cache/BuildCacheStorage.js index 9137a4d0f5d..7a3447ff52f 100644 --- a/packages/project/test/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/test/lib/build/cache/BuildCacheStorage.js @@ -420,6 +420,41 @@ test("findExistingContentIntegrities: Handles large batches", (t) => { t.false(result.has("sha256-nonexistent")); }); +test("hasRecords: Returns false for empty database", (t) => { + t.false(t.context.storage.hasRecords()); +}); + +test("hasRecords: Returns true when content table has records", (t) => { + t.context.storage.putContent("sha256-content", Buffer.from("content")); + t.true(t.context.storage.hasRecords()); +}); + +test("hasRecords: Returns true when index cache table has records", (t) => { + t.context.storage.writeIndexCache("project-a", "build-sig", "source", {v: 1}); + t.true(t.context.storage.hasRecords()); +}); + +test("hasRecords: Returns true when stage metadata table has records", (t) => { + t.context.storage.writeStageCache("project-a", "build-sig", "task/minify", "sig-a", {v: 1}); + t.true(t.context.storage.hasRecords()); +}); + +test("hasRecords: Returns true when task metadata table has records", (t) => { + t.context.storage.writeTaskMetadata("project-a", "build-sig", "minify", "project", {v: 1}); + t.true(t.context.storage.hasRecords()); +}); + +test("hasRecords: Returns true when result metadata table has records", (t) => { + t.context.storage.writeResultMetadata("project-a", "build-sig", "sig-a", {v: 1}); + t.true(t.context.storage.hasRecords()); +}); + +test("getDatabaseSize: Returns positive database size", (t) => { + const size = t.context.storage.getDatabaseSize(); + t.true(Number.isInteger(size)); + t.true(size > 0); +}); + // ===== Pre-compressed content ===== test("putCompressedContent: Stores pre-compressed data retrievable via readContent", (t) => { @@ -471,3 +506,75 @@ test("readContent: Legacy compressed tiny content is still readable", (t) => { t.context.storage.putCompressedContent("sha256-legacy-tiny", compressed); t.deepEqual(t.context.storage.readContent("sha256-legacy-tiny"), content); }); + +// ===== dropAllRecords / hasFreelistPages / vacuum ===== + +test("dropAllRecords: drops all live tables and recreates fresh ones", (t) => { + t.context.storage.writeIndexCache("p", "sig", "source", {value: 1}); + t.context.storage.putContent("sha256-drop-1", Buffer.from("data")); + + const bytesBefore = t.context.storage.dropAllRecords(); + + t.true(bytesBefore > 0, "returns pre-drop byte count"); + t.false(t.context.storage.hasRecords(), "fresh tables are empty after drop"); + t.true(t.context.storage.hasVacuumPending(), "vacuum pending marker set after drop"); +}); + +test("dropAllRecords: fresh tables accept new writes immediately", (t) => { + t.context.storage.writeIndexCache("p", "sig", "source", {old: true}); + t.context.storage.dropAllRecords(); + + t.notThrows(() => { + t.context.storage.writeIndexCache("p", "sig", "source", {new: true}); + }, "can write to fresh tables right after drop"); + + t.deepEqual(t.context.storage.readIndexCache("p", "sig", "source"), {new: true}); +}); + +test("dropAllRecords: calling twice succeeds — second drop operates on freshly-created tables", (t) => { + t.context.storage.putContent("sha256-a", Buffer.from("a")); + t.context.storage.dropAllRecords(); + + t.notThrows(() => t.context.storage.dropAllRecords(), + "second drop succeeds because #createTables recreated the tables in the first call"); + t.false(t.context.storage.hasRecords()); +}); + +test("hasVacuumPending: returns false on a fresh database", (t) => { + t.false(t.context.storage.hasVacuumPending()); +}); + +test("hasVacuumPending: returns true after dropAllRecords", (t) => { + t.context.storage.putContent("sha256-has", Buffer.from("x")); + t.context.storage.dropAllRecords(); + t.true(t.context.storage.hasVacuumPending()); +}); + +test("hasVacuumPending: returns false after vacuum", (t) => { + t.context.storage.putContent("sha256-vac", Buffer.from("y")); + t.context.storage.dropAllRecords(); + t.context.storage.vacuum(); + t.false(t.context.storage.hasVacuumPending()); +}); + +test("vacuum: reclaims space and returns freed bytes", (t) => { + const largeContent = Buffer.alloc(64 * 1024, "x"); + t.context.storage.putContent("sha256-large-vac", largeContent); + t.context.storage.dropAllRecords(); + + const freed = t.context.storage.vacuum(); + + t.true(freed >= 0, "freed bytes is non-negative"); + t.false(t.context.storage.hasVacuumPending(), "vacuum pending cleared after vacuum"); +}); + +test("vacuum: live data written after drop is unaffected", (t) => { + t.context.storage.putContent("sha256-old", Buffer.from("old")); + t.context.storage.dropAllRecords(); + t.context.storage.writeIndexCache("p", "sig", "source", {fresh: true}); + + t.context.storage.vacuum(); + + t.deepEqual(t.context.storage.readIndexCache("p", "sig", "source"), {fresh: true}, + "data written after drop survives vacuum"); +}); diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index e02d0850d48..dd89c030446 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -203,3 +203,147 @@ test.serial("transaction: throwing rolls back metadata and content writes", asyn "Metadata should not exist after rollback"); cm.close(); }); + +// Static cleanup/info helpers + +test.serial("getCacheInfo: Returns null when cache db is not available", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + + const info = await CacheManager.getCacheInfo(testDir); + t.is(info, null); +}); + +test.serial("getCacheInfo: Returns null when cache has no records", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.close(); + + const info = await CacheManager.getCacheInfo(testDir); + t.is(info, null); +}); + +test.serial("getCacheInfo: Returns cache info when records exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-x", "build-sig", "source", {value: 1}); + cm.close(); + + const info = await CacheManager.getCacheInfo(testDir); + t.truthy(info); + t.true(Number.isInteger(info.size)); + t.true(info.size > 0); +}); + +test.serial("cleanCache: Clears records and returns removal result", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-x", "build-sig", "source", {value: 1}); + cm.putContent("sha256-clean", Buffer.from("content")); + cm.close(); + + const result = await CacheManager.cleanCache(testDir); + t.truthy(result); + t.true(Number.isInteger(result.size)); + t.true(result.size >= 0); + + const infoAfterClean = await CacheManager.getCacheInfo(testDir); + t.is(infoAfterClean, null, "getCacheInfo returns null for empty fresh tables after cleanCache"); + + const additionalInfo = await CacheManager.getAdditionalCacheInfo(testDir); + t.is(additionalInfo.length, 1, "vacuum pending reported as additional info"); + t.true(additionalInfo[0].size > 0); +}); + +test.serial("cleanCache: returns null when no records exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.close(); + + const result = await CacheManager.cleanCache(testDir); + t.is(result, null); +}); + +test.serial("cleanCache: returns null when db does not exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + + const result = await CacheManager.cleanCache(testDir); + t.is(result, null); +}); + +test.serial("getAdditionalCacheInfo: returns empty array when no stale tables", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("p", "sig", "source", {v: 1}); + cm.close(); + + const info = await CacheManager.getAdditionalCacheInfo(testDir); + t.deepEqual(info, []); +}); + +test.serial("getAdditionalCacheInfo: returns empty array when db does not exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + + const info = await CacheManager.getAdditionalCacheInfo(testDir); + t.deepEqual(info, []); +}); + +test.serial("getAdditionalCacheInfo: reports stale tables after cleanCache", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("p", "sig", "source", {v: 1}); + cm.close(); + + await CacheManager.cleanCache(testDir); + + const info = await CacheManager.getAdditionalCacheInfo(testDir); + t.is(info.length, 1); + t.true(info[0].size > 0); + t.truthy(info[0].path); +}); + +test.serial("cleanAdditional: returns empty array when no stale tables", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("p", "sig", "source", {v: 1}); + cm.close(); + + const result = await CacheManager.cleanAdditional(testDir); + t.deepEqual(result, []); +}); + +test.serial("cleanAdditional: returns empty array when db does not exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + + const result = await CacheManager.cleanAdditional(testDir); + t.deepEqual(result, []); +}); + +test.serial("cleanAdditional: drops stale tables, returns freed size, leaves nothing pending", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("p", "sig", "source", {v: 1}); + cm.putContent("sha256-cleanup", Buffer.alloc(64 * 1024, "z")); + cm.close(); + + await CacheManager.cleanCache(testDir); + + const result = await CacheManager.cleanAdditional(testDir); + t.is(result.length, 1); + t.true(result[0].size >= 0); + t.truthy(result[0].path); + + const remainingAdditional = await CacheManager.getAdditionalCacheInfo(testDir); + t.deepEqual(remainingAdditional, [], "no stale tables remain after cleanAdditional"); +}); diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 684e8634a84..22bafac69f5 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -13,19 +13,21 @@ 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, 16); }); // Public API contract (exported modules) [ "config/Configuration", "build/cache/Cache", + {exportedSpecifier: "internal/build/cache/CacheManager", mappedModule: "../../lib/build/cache/CacheManager.js"}, "specifications/Specification", "specifications/SpecificationVersion", "ui5Framework/Openui5Resolver", "ui5Framework/Sapui5Resolver", "ui5Framework/Sapui5MavenSnapshotResolver", "ui5Framework/maven/SnapshotCache", + {exportedSpecifier: "internal/ui5Framework/cache", mappedModule: "../../lib/ui5Framework/cache.js"}, "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..b90308b344b --- /dev/null +++ b/packages/project/test/lib/ui5framework/cache.js @@ -0,0 +1,339 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import sinon from "sinon"; +import esmock from "esmock"; +import FrameworkCache 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 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 FrameworkCache.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 FrameworkCache.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 FrameworkCache.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 mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.ui.core", "1.120.0"); + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.ui.core", "1.148.0"); + await mkPackageIn(path.join(t.context.testDir, "framework"), "@sapui5", "sap.m", "1.38.1"); + + const result = await FrameworkCache.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 mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.ui.core", "1.120.0"); + + const result = await FrameworkCache.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 mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + + const result = await FrameworkCache.getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.libraryCount, 1); + t.is(result.versionCount, 1); +}); + +test("getCacheInfo: skips unreadable subdirectories without throwing", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await mkPackageIn(frameworkDir, "@openui5", "sap.m", "1.120.0"); + await mkPackageIn(frameworkDir, "@sapui5", "sap.ui.core", "1.110.0"); + + const unreadableScopeDir = path.join(frameworkDir, "packages", "@sapui5"); + const readdirStub = sinon.stub().callsFake(async (dirPath, opts) => { + if (dirPath === unreadableScopeDir) { + const err = Object.assign(new Error("EACCES: permission denied, scandir"), {code: "EACCES"}); + throw err; + } + return fs.readdir(dirPath, opts); + }); + + const FrameworkCacheMocked = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, readdir: readdirStub}} + ); + + try { + const result = await FrameworkCacheMocked.getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.libraryCount, 1); + t.is(result.versionCount, 1); + } finally { + esmock.purge(FrameworkCacheMocked); + } +}); + +// ─── cleanCache ─────────────────────────────────────────────────────────────── + +test("cleanCache: returns null for non-existent framework directory", async (t) => { + const result = await FrameworkCache.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 FrameworkCache.cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: renames then removes framework directory and returns stats", async (t) => { + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.ui.core", "1.120.0"); + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.ui.core", "1.148.0"); + + const frameworkDir = path.join(t.context.testDir, "framework"); + const result = await FrameworkCache.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 FrameworkCache.getCacheInfo(t.context.testDir), null); + + // No stale removal 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 stale removal 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 mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + await mkPackageIn(path.join(t.context.testDir, "framework"), "@sapui5", "sap.m", "1.38.1"); + + const result = await FrameworkCache.cleanCache(t.context.testDir); + + t.truthy(result); + t.is(result.libraryCount, 1); // sap.m deduplicated + t.is(result.versionCount, 2); + + t.is(await FrameworkCache.getCacheInfo(t.context.testDir), null); +}); + +test("cleanCache: does not include stale field in result", async (t) => { + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + + const result = await FrameworkCache.cleanCache(t.context.testDir); + + t.truthy(result); + t.false(Object.prototype.hasOwnProperty.call(result, "stale"), + "cleanCache result does not include stale — use cleanAdditional for that"); +}); + +test("cleanCache: does not remove stale removal dirs — that is cleanAdditional's job", async (t) => { + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + + const staleDir = path.join(t.context.testDir, "_framework_to_delete_abcd"); + await mkPackageIn(staleDir, "@openui5", "sap.ui.core", "1.100.0"); + + await FrameworkCache.cleanCache(t.context.testDir); + + // Stale removal dir is still present after cleanCache — cleanAdditional handles it + await t.notThrowsAsync(fs.access(staleDir), "stale removal dir is not touched by cleanCache"); +}); + +// ─── cleanAdditional ────────────────────────────────────────────────────────── + +test("cleanAdditional: returns empty array when no stale removal dirs exist", async (t) => { + const result = await FrameworkCache.cleanAdditional(t.context.testDir); + t.deepEqual(result, []); +}); + +test("cleanAdditional: detects and removes stale removal dirs, reports them", async (t) => { + const staleDir = path.join(t.context.testDir, "_framework_to_delete_abcd"); + await mkPackageIn(staleDir, "@openui5", "sap.ui.core", "1.100.0"); + await mkPackageIn(staleDir, "@openui5", "sap.ui.core", "1.110.0"); + + const result = await FrameworkCache.cleanAdditional(t.context.testDir); + + t.is(result.length, 1, "one stale removal dir reported"); + const staleResult = result[0]; + t.true(staleResult.path.startsWith("_framework_to_delete_"), "stale path has pending-removal prefix"); + t.is(staleResult.libraryCount, 1); + t.is(staleResult.versionCount, 2); + + await t.throwsAsync(fs.access(staleDir), {code: "ENOENT"}, "stale removal dir removed"); +}); + +test("cleanAdditional: removes multiple stale removal dirs and reports each", async (t) => { + const stale1 = path.join(t.context.testDir, "_framework_to_delete_1111"); + const stale2 = path.join(t.context.testDir, "_framework_to_delete_2222"); + + await mkPackageIn(stale1, "@openui5", "sap.m", "1.90.0"); + await mkPackageIn(stale2, "@openui5", "sap.ui.core", "1.91.0"); + await mkPackageIn(stale2, "@openui5", "sap.ui.core", "1.92.0"); + + const result = await FrameworkCache.cleanAdditional(t.context.testDir); + + t.is(result.length, 2, "two stale removal 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(stale1), {code: "ENOENT"}); + await t.throwsAsync(fs.access(stale2), {code: "ENOENT"}); +}); + +test("cleanAdditional: stale removal dir deletion failure is non-fatal", async (t) => { + const staleDir = path.join(t.context.testDir, "_framework_to_delete_fail"); + await mkPackageIn(staleDir, "@openui5", "sap.m", "1.80.0"); + + const rmStub = sinon.stub().callsFake(async (p, opts) => { + if (p === staleDir) { + throw new Error("simulated deletion failure"); + } + return fs.rm(p, opts); + }); + + const FrameworkCacheMocked = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, rm: rmStub}} + ); + + try { + const result = await FrameworkCacheMocked.cleanAdditional(t.context.testDir); + t.deepEqual(result, [], "failed deletion is excluded from the returned list"); + await t.notThrowsAsync(fs.access(staleDir), "failed stale removal dir deletion keeps directory on disk"); + } finally { + esmock.purge(FrameworkCacheMocked); + await fs.rm(staleDir, {recursive: true, force: true}).catch(() => {}); + } +}); + +test("cleanAdditional: returns only successfully removed stale removal dirs", async (t) => { + const staleOk = path.join(t.context.testDir, "_framework_to_delete_ok"); + const staleFail = path.join(t.context.testDir, "_framework_to_delete_fail"); + await mkPackageIn(staleOk, "@openui5", "sap.m", "1.80.0"); + await mkPackageIn(staleFail, "@openui5", "sap.ui.core", "1.81.0"); + + const rmStub = sinon.stub().callsFake(async (p, opts) => { + if (p === staleFail) { + throw new Error("simulated deletion failure"); + } + return fs.rm(p, opts); + }); + + const FrameworkCacheMocked = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, rm: rmStub}} + ); + + try { + const result = await FrameworkCacheMocked.cleanAdditional(t.context.testDir); + t.is(result.length, 1); + t.is(result[0].path, "_framework_to_delete_ok"); + + await t.throwsAsync(fs.access(staleOk), {code: "ENOENT"}); + await t.notThrowsAsync(fs.access(staleFail)); + } finally { + esmock.purge(FrameworkCacheMocked); + await fs.rm(staleFail, {recursive: true, force: true}).catch(() => {}); + } +}); + +test("cleanCache: returns null if framework dir removed between check and rename (ENOENT race)", async (t) => { + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + + const frameworkDir = path.join(t.context.testDir, "framework"); + const renameStub = sinon.stub().callsFake(async (oldPath, newPath) => { + if (oldPath === frameworkDir) { + const err = Object.assign(new Error("ENOENT: no such file or directory, rename"), {code: "ENOENT"}); + throw err; + } + return fs.rename(oldPath, newPath); + }); + + const FrameworkCacheMocked = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, rename: renameStub}} + ); + + try { + const result = await FrameworkCacheMocked.cleanCache(t.context.testDir); + t.is(result, null, "returns null when directory is concurrently removed"); + } finally { + esmock.purge(FrameworkCacheMocked); + await fs.rm(frameworkDir, {recursive: true, force: true}).catch(() => {}); + } +}); + +test("cleanCache: re-throws non-ENOENT errors from fs.rename", async (t) => { + await mkPackageIn(path.join(t.context.testDir, "framework"), "@openui5", "sap.m", "1.120.0"); + + const frameworkDir = path.join(t.context.testDir, "framework"); + const renameStub = sinon.stub().callsFake(async (oldPath, newPath) => { + if (oldPath === frameworkDir) { + const err = Object.assign(new Error("EACCES: permission denied, rename"), {code: "EACCES"}); + throw err; + } + return fs.rename(oldPath, newPath); + }); + + const FrameworkCacheMocked = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, rename: renameStub}} + ); + + try { + const error = await t.throwsAsync(FrameworkCacheMocked.cleanCache(t.context.testDir)); + t.is(/** @type {NodeJS.ErrnoException} */ (error).code, "EACCES"); + } finally { + esmock.purge(FrameworkCacheMocked); + await fs.rm(frameworkDir, {recursive: true, force: true}).catch(() => {}); + } +}); +