diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 7fe8c4486a3..72e541a2779 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 (for example, because the process was killed or the system crashed), the command also detects and removes any leftover data from that interrupted operation. This data is 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/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(() => {}); + } +}); +