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