From 9bcacaf145196c12ae88443b754aad26d6bf4159 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 22 May 2026 15:16:52 +0300 Subject: [PATCH 01/18] feat: Clean cache command --- packages/cli/lib/cli/commands/cache.js | 78 +++++++++ packages/cli/test/lib/cli/commands/cache.js | 81 +++++++++ packages/project/lib/cache/CacheCleanup.js | 165 ++++++++++++++++++ packages/project/package.json | 1 + .../project/test/lib/cache/CacheCleanup.js | 109 ++++++++++++ 5 files changed, 434 insertions(+) create mode 100644 packages/cli/lib/cli/commands/cache.js create mode 100644 packages/cli/test/lib/cli/commands/cache.js create mode 100644 packages/project/lib/cache/CacheCleanup.js create mode 100644 packages/project/test/lib/cache/CacheCleanup.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..ae4ca03f61c --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -0,0 +1,78 @@ +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 {cleanCache} from "@ui5/project/cache/CacheCleanup"; + +const cacheCommand = { + command: "cache", + describe: "Manage UI5 CLI cache", + 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: noop, + middlewares: [baseMiddleware], + }) + .example("$0 cache clean", + "Remove all cached UI5 data"); +}; + +function noop() {} + +/** + * 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`; +} + +async function handleCache() { + // Resolve UI5 data directory + let ui5DataDir = process.env.UI5_DATA_DIR; + if (!ui5DataDir) { + const config = await Configuration.fromFile(); + ui5DataDir = config.getUi5DataDir(); + } + if (ui5DataDir) { + ui5DataDir = path.resolve(process.cwd(), ui5DataDir); + } else { + ui5DataDir = path.join(os.homedir(), ".ui5"); + } + + const result = await cleanCache({ui5DataDir}); + + if (result.totalCount === 0) { + process.stderr.write("Nothing to clean\n"); + return; + } + + for (const entry of result.entries) { + const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : ""; + process.stderr.write(`Removed ${chalk.bold(entry.path)}${sizeStr}\n`); + } + + process.stderr.write( + `\nCleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` + + (result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n" + ); +} + +export default cacheCommand; 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..53fb40d1a22 --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,81 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import Configuration from "@ui5/project/config/Configuration"; + +function getDefaultArgv() { + return { + "_": ["cache", "clean"], + "loglevel": "info", + "log-level": "info", + "logLevel": "info", + "perf": false, + "silent": false, + "$0": "ui5" + }; +} + +test.beforeEach(async (t) => { + t.context.argv = getDefaultArgv(); + + t.context.stderrWriteStub = sinon.stub(process.stderr, "write"); + + t.context.Configuration = Configuration; + sinon.stub(Configuration, "fromFile").resolves(new Configuration({})); + + t.context.cleanCacheStub = sinon.stub(); + + t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { + "@ui5/project/config/Configuration": t.context.Configuration, + "@ui5/project/cache/CacheCleanup": { + cleanCache: t.context.cleanCacheStub, + }, + }); +}); + +test.afterEach.always((t) => { + sinon.restore(); + esmock.purge(t.context.cache); +}); + +test.serial("ui5 cache clean: nothing to clean", async (t) => { + const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context; + + cleanCacheStub.resolves({entries: [], totalSize: 0, totalCount: 0}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n"); +}); + +test.serial("ui5 cache clean: removes entries and reports", async (t) => { + const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context; + + cleanCacheStub.resolves({ + entries: [ + {path: "@openui5/sap.ui.core/1.120.0", type: "framework", size: 15 * 1024 * 1024}, + {path: "@openui5/sap.m/1.120.0", type: "framework", size: 8 * 1024 * 1024}, + ], + totalSize: 23 * 1024 * 1024, + totalCount: 2, + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + // Should have 4 writes: 2 entries + 1 newline + summary + t.true(stderrWriteStub.callCount >= 3, "Multiple lines written to stderr"); + // Check that summary mentions entries count + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("2 entries"), "Summary mentions entry count"); + t.true(allOutput.includes("23.0 MB"), "Summary mentions freed size"); +}); + +test("Command definition is correct", (t) => { + // Import without esmock for structure check + t.is(t.context.cache.command, "cache"); + t.is(t.context.cache.describe, "Manage UI5 CLI cache"); + t.is(typeof t.context.cache.builder, "function"); + t.is(typeof t.context.cache.handler, "function"); +}); diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js new file mode 100644 index 00000000000..63b243c5535 --- /dev/null +++ b/packages/project/lib/cache/CacheCleanup.js @@ -0,0 +1,165 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import {DatabaseSync} from "node:sqlite"; + +/** + * Get the size of a directory tree recursively. + * + * @param {string} dirPath Absolute path to directory + * @returns {Promise} Total size in bytes + */ +async function getDirectorySize(dirPath) { + let total = 0; + let entries; + try { + entries = await fs.readdir(dirPath, {withFileTypes: true}); + } catch { + return 0; + } + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + total += await getDirectorySize(entryPath); + } else { + try { + const stat = await fs.stat(entryPath); + total += stat.size; + } catch { + // Skip inaccessible files + } + } + } + return total; +} + +/** + * Clean a single directory by removing it entirely. + * + * @param {string} dirPath Absolute path to directory + * @param {string} displayPath Path to display in results + * @param {string} type Type of cache entry + * @returns {Promise>} Removed entries + */ +async function cleanDirectory(dirPath, displayPath, type) { + const removed = []; + try { + await fs.access(dirPath); + } catch { + return removed; + } + + const size = await getDirectorySize(dirPath); + try { + await fs.rm(dirPath, {recursive: true, force: true}); + removed.push({path: displayPath, type, size}); + } catch { + // Skip on failure + } + return removed; +} + +/** + * Clean build cache directory by clearing all records from the SQLite database. + * + * @param {string} buildCacheDir Path to buildCache/ + * @returns {Promise>} Removed entries + */ +async function cleanBuildCache(buildCacheDir) { + const removed = []; + try { + await fs.access(buildCacheDir); + } catch { + return removed; + } + + let versionDirs; + try { + versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + } catch { + return removed; + } + + const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; + + for (const versionDir of versionDirs) { + if (!versionDir.isDirectory()) { + continue; + } + + const dbPath = path.join(buildCacheDir, versionDir.name, "cache.db"); + try { + await fs.access(dbPath); + } catch { + continue; + } + + const statBefore = await fs.stat(dbPath); + const sizeBefore = statBefore.size; + + const db = new DatabaseSync(dbPath); + db.exec("BEGIN"); + for (const table of tables) { + db.exec(`DELETE FROM ${table}`); + } + db.exec("COMMIT"); + db.exec("VACUUM"); + db.close(); + + const statAfter = await fs.stat(dbPath); + const freedSize = sizeBefore - statAfter.size; + + removed.push({ + path: `buildCache/${versionDir.name}`, + type: "buildCache", + size: freedSize, + }); + } + + return removed; +} + +/** + * Scans the UI5 data directory and removes all cache entries. + * + * @param {object} options + * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{entries: Array<{path: string, type: string, size: number}>, + * totalSize: number, totalCount: number}>} + */ +export async function cleanCache({ui5DataDir}) { + const allRemoved = []; + + // Clean framework packages + allRemoved.push(...await cleanDirectory( + path.join(ui5DataDir, "framework", "packages"), + "framework/packages", + "framework" + )); + + // Clean cacache + allRemoved.push(...await cleanDirectory( + path.join(ui5DataDir, "framework", "cacache"), + "framework/cacache", + "cacache" + )); + + // Clean build cache (special: clears DB records, not files) + allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); + + // Clean misc dirs + const miscDirs = [ + ["framework/staging", "staging"], + ["framework/locks", "locks"], + ["server", "server"], + ]; + for (const [rel, type] of miscDirs) { + allRemoved.push(...await cleanDirectory(path.join(ui5DataDir, rel), rel, type)); + } + + const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); + return { + entries: allRemoved, + totalSize, + totalCount: allRemoved.length, + }; +} diff --git a/packages/project/package.json b/packages/project/package.json index d6fb584b4d6..3fead1c7555 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -31,6 +31,7 @@ "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", "./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", + "./cache/CacheCleanup": "./lib/cache/CacheCleanup.js", "./package.json": "./package.json" }, "engines": { diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js new file mode 100644 index 00000000000..04c0ea3e208 --- /dev/null +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -0,0 +1,109 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import {rimraf} from "rimraf"; +import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); + +test.after.always(async () => { + await rimraf(TEST_DIR).catch(() => {}); +}); + +/** + * Create a unique test directory for each test. + * + * @param {object} t AVA test context + * @returns {string} Path to the ui5DataDir fixture + */ +function createTestDir(t) { + const dir = path.join(TEST_DIR, `test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + t.context.ui5DataDir = dir; + return dir; +} + +/** + * Create a framework package fixture. + * + * @param {string} ui5DataDir Base data directory + * @param {string} scope Package scope (e.g., "@openui5") + * @param {string} name Package name (e.g., "sap.ui.core") + * @param {string} version Version string + * @param {object} [options] + * @param {Date} [options.mtime] Custom mtime for the package file + * @returns {Promise} + */ +async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { + const pkgDir = path.join(ui5DataDir, "framework", "packages", scope, name, version); + await fs.mkdir(pkgDir, {recursive: true}); + const filePath = path.join(pkgDir, "package.json"); + await fs.writeFile(filePath, JSON.stringify({name: `${scope}/${name}`, version})); + if (mtime) { + await fs.utimes(filePath, mtime, mtime); + } +} + +// ===== cleanCache: empty/nonexistent dir ===== + +test("cleanCache: returns empty result for nonexistent directory", async (t) => { + const result = await cleanCache({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); + t.is(result.totalCount, 0); + t.is(result.totalSize, 0); + t.deepEqual(result.entries, []); +}); + +// ===== cleanCache: clean all ===== + +test("cleanCache: clean all removes framework packages", async (t) => { + const ui5DataDir = createTestDir(t); + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + await createPackage(ui5DataDir, "@openui5", "sap.m", "1.120.0"); + + const result = await cleanCache({ui5DataDir}); + + t.true(result.totalCount >= 1); + const frameworkEntries = result.entries.filter((e) => e.type === "framework"); + t.is(frameworkEntries.length, 1); + t.is(frameworkEntries[0].path, "framework/packages"); +}); + +// ===== cleanCache: build cache (full clean) ===== + +test("cleanCache: clean all clears buildCache database", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + // Create a real SQLite database with tables and some data + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); + CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); + CREATE TABLE stage_metadata + (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); + CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); + CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); + `); + db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); + db.exec("INSERT INTO index_cache VALUES ('proj', 'sig', 'source', 'data')"); + db.close(); + + const result = await cleanCache({ui5DataDir}); + + const buildCacheEntry = result.entries.find((e) => e.type === "buildCache"); + t.truthy(buildCacheEntry); + + // Verify directory and DB file still exist + await fs.access(buildCacheDir); + await fs.access(dbPath); + + // Verify tables are empty + const dbAfter = new DatabaseSync(dbPath); + const contentCount = dbAfter.prepare("SELECT COUNT(*) as count FROM content").get().count; + const indexCount = dbAfter.prepare("SELECT COUNT(*) as count FROM index_cache").get().count; + t.is(contentCount, 0); + t.is(indexCount, 0); + dbAfter.close(); +}); From d49b45d15f3463dd78cf77df8126ca02cf3a9686 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 11:48:58 +0300 Subject: [PATCH 02/18] refactor: Use single place for DB manipulation --- .../lib/build/cache/BuildCacheStorage.js | 26 ++++++++++++++++++ packages/project/lib/cache/CacheCleanup.js | 27 ++++--------------- packages/project/test/lib/package-exports.js | 2 +- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index e2d9bff9a6a..fe4fe2b8581 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -550,6 +550,32 @@ 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 {page_count: pageCountBefore} = this.#db.prepare("PRAGMA page_count").get(); + const {page_size: pageSize} = this.#db.prepare("PRAGMA page_size").get(); + const bytesBefore = pageCountBefore * pageSize; + + 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 {page_count: pageCountAfter} = this.#db.prepare("PRAGMA page_count").get(); + const bytesAfter = pageCountAfter * pageSize; + + return bytesBefore - bytesAfter; + } + /** * Closes the database connection */ diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js index 63b243c5535..7e94f6a2521 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "node:fs/promises"; -import {DatabaseSync} from "node:sqlite"; +import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; /** * Get the size of a directory tree recursively. @@ -79,34 +79,17 @@ async function cleanBuildCache(buildCacheDir) { return removed; } - const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; for (const versionDir of versionDirs) { if (!versionDir.isDirectory()) { continue; } - const dbPath = path.join(buildCacheDir, versionDir.name, "cache.db"); - try { - await fs.access(dbPath); - } catch { - continue; - } - - const statBefore = await fs.stat(dbPath); - const sizeBefore = statBefore.size; - - const db = new DatabaseSync(dbPath); - db.exec("BEGIN"); - for (const table of tables) { - db.exec(`DELETE FROM ${table}`); - } - db.exec("COMMIT"); - db.exec("VACUUM"); - db.close(); + const dbDir = path.join(buildCacheDir, versionDir.name); - const statAfter = await fs.stat(dbPath); - const freedSize = sizeBefore - statAfter.size; + const storage = new BuildCacheStorage(dbDir); + const freedSize = storage.clearAllRecords(); + storage.close(); removed.push({ path: `buildCache/${versionDir.name}`, diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 684e8634a84..ec16c6e22bc 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, 14); + t.is(Object.keys(packageJson.exports).length, 15); }); // Public API contract (exported modules) From c64092a93b29811617a7eceaa5a64c6532b1c88c Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 13:10:28 +0300 Subject: [PATCH 03/18] refactor: Simplify cache clean --- packages/project/lib/cache/CacheCleanup.js | 71 ++++++------------- .../project/test/lib/cache/CacheCleanup.js | 2 +- 2 files changed, 21 insertions(+), 52 deletions(-) diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js index 7e94f6a2521..7f90f15547c 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -32,32 +32,6 @@ async function getDirectorySize(dirPath) { return total; } -/** - * Clean a single directory by removing it entirely. - * - * @param {string} dirPath Absolute path to directory - * @param {string} displayPath Path to display in results - * @param {string} type Type of cache entry - * @returns {Promise>} Removed entries - */ -async function cleanDirectory(dirPath, displayPath, type) { - const removed = []; - try { - await fs.access(dirPath); - } catch { - return removed; - } - - const size = await getDirectorySize(dirPath); - try { - await fs.rm(dirPath, {recursive: true, force: true}); - removed.push({path: displayPath, type, size}); - } catch { - // Skip on failure - } - return removed; -} - /** * Clean build cache directory by clearing all records from the SQLite database. * @@ -102,7 +76,11 @@ async function cleanBuildCache(buildCacheDir) { } /** - * Scans the UI5 data directory and removes all cache entries. + * Cleans cache directories for framework libraries and incremental build cache. + * + * Removes: + * - framework/ directory: All UI5 framework libraries, download cache, staging files, and locks + * - buildCache/ entries: Clears database records (preserves database files) * * @param {object} options * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory @@ -112,33 +90,24 @@ async function cleanBuildCache(buildCacheDir) { export async function cleanCache({ui5DataDir}) { const allRemoved = []; - // Clean framework packages - allRemoved.push(...await cleanDirectory( - path.join(ui5DataDir, "framework", "packages"), - "framework/packages", - "framework" - )); - - // Clean cacache - allRemoved.push(...await cleanDirectory( - path.join(ui5DataDir, "framework", "cacache"), - "framework/cacache", - "cacache" - )); + // Clean entire framework directory (packages, cacache, staging, locks, etc.) + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.access(frameworkDir); + const size = await getDirectorySize(frameworkDir); + await fs.rm(frameworkDir, {recursive: true, force: true}); + allRemoved.push({ + path: "framework", + type: "framework", + size + }); + } catch { + // Framework directory doesn't exist or couldn't be removed + } - // Clean build cache (special: clears DB records, not files) + // Clean build cache (clears DB records, preserves files) allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); - // Clean misc dirs - const miscDirs = [ - ["framework/staging", "staging"], - ["framework/locks", "locks"], - ["server", "server"], - ]; - for (const [rel, type] of miscDirs) { - allRemoved.push(...await cleanDirectory(path.join(ui5DataDir, rel), rel, type)); - } - const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); return { entries: allRemoved, diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js index 04c0ea3e208..7340b807cea 100644 --- a/packages/project/test/lib/cache/CacheCleanup.js +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -64,7 +64,7 @@ test("cleanCache: clean all removes framework packages", async (t) => { t.true(result.totalCount >= 1); const frameworkEntries = result.entries.filter((e) => e.type === "framework"); t.is(frameworkEntries.length, 1); - t.is(frameworkEntries[0].path, "framework/packages"); + t.is(frameworkEntries[0].path, "framework"); }); // ===== cleanCache: build cache (full clean) ===== From 30df6b547176bbf73eb7bbe925bdeca0b14fd83a Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:00:12 +0300 Subject: [PATCH 04/18] refactor: Position correctly the CacheCleanup --- packages/cli/lib/cli/commands/cache.js | 2 +- packages/cli/test/lib/cli/commands/cache.js | 2 +- packages/project/lib/{ => build}/cache/CacheCleanup.js | 2 +- packages/project/package.json | 2 +- packages/project/test/lib/{ => build}/cache/CacheCleanup.js | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename packages/project/lib/{ => build}/cache/CacheCleanup.js (97%) rename packages/project/test/lib/{ => build}/cache/CacheCleanup.js (96%) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index ae4ca03f61c..2409d055521 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -4,7 +4,7 @@ import os from "node:os"; import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; -import {cleanCache} from "@ui5/project/cache/CacheCleanup"; +import {cleanCache} from "@ui5/project/build/cache/CacheCleanup"; const cacheCommand = { command: "cache", diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 53fb40d1a22..4f990c82631 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -27,7 +27,7 @@ test.beforeEach(async (t) => { t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, - "@ui5/project/cache/CacheCleanup": { + "@ui5/project/build/cache/CacheCleanup": { cleanCache: t.context.cleanCacheStub, }, }); diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/build/cache/CacheCleanup.js similarity index 97% rename from packages/project/lib/cache/CacheCleanup.js rename to packages/project/lib/build/cache/CacheCleanup.js index 7f90f15547c..b5c929caa07 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/build/cache/CacheCleanup.js @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "node:fs/promises"; -import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; +import BuildCacheStorage from "./BuildCacheStorage.js"; /** * Get the size of a directory tree recursively. diff --git a/packages/project/package.json b/packages/project/package.json index 3fead1c7555..ee1034e6c7f 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -31,7 +31,7 @@ "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", "./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", - "./cache/CacheCleanup": "./lib/cache/CacheCleanup.js", + "./build/cache/CacheCleanup": "./lib/build/cache/CacheCleanup.js", "./package.json": "./package.json" }, "engines": { diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/build/cache/CacheCleanup.js similarity index 96% rename from packages/project/test/lib/cache/CacheCleanup.js rename to packages/project/test/lib/build/cache/CacheCleanup.js index 7340b807cea..62daad3aa15 100644 --- a/packages/project/test/lib/cache/CacheCleanup.js +++ b/packages/project/test/lib/build/cache/CacheCleanup.js @@ -2,9 +2,9 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import {rimraf} from "rimraf"; -import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; +import {cleanCache} from "../../../../lib/build/cache/CacheCleanup.js"; -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "CacheCleanup"); test.after.always(async () => { await rimraf(TEST_DIR).catch(() => {}); From 7318ffe37554fe5567ec8a92011325faf07dd038 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:48:44 +0300 Subject: [PATCH 05/18] refactor: Add confirmation dialog for the cache clean command --- packages/cli/lib/cli/commands/cache.js | 53 ++++- packages/cli/test/lib/cli/commands/cache.js | 200 +++++++++++++++++- .../lib/build/cache/BuildCacheStorage.js | 15 ++ .../project/lib/build/cache/CacheCleanup.js | 106 ++++++++-- 4 files changed, 343 insertions(+), 31 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 2409d055521..ab254ca546e 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -2,9 +2,10 @@ import chalk from "chalk"; import path from "node:path"; import os from "node:os"; import process from "node:process"; +import readline from "node:readline"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; -import {cleanCache} from "@ui5/project/build/cache/CacheCleanup"; +import {cleanCache, getCacheInfo} from "@ui5/project/build/cache/CacheCleanup"; const cacheCommand = { command: "cache", @@ -44,6 +45,26 @@ function formatSize(bytes) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } +/** + * Prompt user for confirmation. + * + * @param {string} question The question to ask + * @returns {Promise} True if user confirmed + */ +async function confirm(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); +} + async function handleCache() { // Resolve UI5 data directory let ui5DataDir = process.env.UI5_DATA_DIR; @@ -57,20 +78,42 @@ async function handleCache() { ui5DataDir = path.join(os.homedir(), ".ui5"); } - const result = await cleanCache({ui5DataDir}); + // Check what items exist before cleaning + const items = await getCacheInfo({ui5DataDir}); - if (result.totalCount === 0) { + if (items.length === 0) { process.stderr.write("Nothing to clean\n"); return; } + // Display items that will be removed + process.stderr.write(chalk.bold("\nThe following items from cache will be removed:\n")); + let totalSize = 0; + for (const item of items) { + totalSize += item.size; + const sizeStr = item.size > 0 ? ` (${formatSize(item.size)})` : ""; + process.stderr.write(` ${chalk.yellow("•")} ${item.path}${sizeStr}\n`); + } + process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`)); + + // Ask for confirmation + const confirmed = await confirm("Do you want to continue? (y/N) "); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } + + // Perform the actual cleanup + const result = await cleanCache({ui5DataDir}); + + process.stderr.write("\n"); for (const entry of result.entries) { const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : ""; - process.stderr.write(`Removed ${chalk.bold(entry.path)}${sizeStr}\n`); + process.stderr.write(`${chalk.green("✓")} Removed ${chalk.bold(entry.path)}${sizeStr}\n`); } process.stderr.write( - `\nCleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` + + `\n${chalk.green("Success:")} Cleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` + (result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n" ); } diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 4f990c82631..eff92cbd1f7 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -24,11 +24,24 @@ test.beforeEach(async (t) => { sinon.stub(Configuration, "fromFile").resolves(new Configuration({})); t.context.cleanCacheStub = sinon.stub(); + t.context.getCacheInfoStub = sinon.stub(); + + // Mock readline to simulate user confirmation + const mockRLInterface = { + question: sinon.stub(), + close: sinon.stub() + }; + t.context.readlineCreateInterfaceStub = sinon.stub().returns(mockRLInterface); + t.context.mockRLInterface = mockRLInterface; t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, "@ui5/project/build/cache/CacheCleanup": { cleanCache: t.context.cleanCacheStub, + getCacheInfo: t.context.getCacheInfoStub, + }, + "node:readline": { + createInterface: t.context.readlineCreateInterfaceStub, }, }); }); @@ -38,24 +51,53 @@ test.afterEach.always((t) => { esmock.purge(t.context.cache); }); +test("Command builder", async (t) => { + // Import cache module directly for builder test (before beforeEach stubs are created) + 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, 1, "example called once"); +}); + test.serial("ui5 cache clean: nothing to clean", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context; + const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub} = t.context; - cleanCacheStub.resolves({entries: [], totalSize: 0, totalCount: 0}); + // Simulate no cache items + getCacheInfoStub.resolves([]); argv["_"] = ["cache", "clean"]; await cache.handler(argv); t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n"); + t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called"); }); test.serial("ui5 cache clean: removes entries and reports", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context; + const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, + mockRLInterface} = t.context; + + // Simulate existing cache items + getCacheInfoStub.resolves([ + {path: "framework/", size: 15 * 1024 * 1024, type: "directory"}, + {path: "buildCache/ (database records)", size: 8 * 1024 * 1024, type: "database"}, + ]); + + // Mock user confirmation + mockRLInterface.question.callsFake((question, callback) => { + callback("y"); + }); cleanCacheStub.resolves({ entries: [ - {path: "@openui5/sap.ui.core/1.120.0", type: "framework", size: 15 * 1024 * 1024}, - {path: "@openui5/sap.m/1.120.0", type: "framework", size: 8 * 1024 * 1024}, + {path: "framework", type: "framework", size: 15 * 1024 * 1024}, + {path: "buildCache", type: "buildCache", size: 8 * 1024 * 1024}, ], totalSize: 23 * 1024 * 1024, totalCount: 2, @@ -64,18 +106,156 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { argv["_"] = ["cache", "clean"]; await cache.handler(argv); - // Should have 4 writes: 2 entries + 1 newline + summary - t.true(stderrWriteStub.callCount >= 3, "Multiple lines written to stderr"); - // Check that summary mentions entries count + // Check that confirmation was asked + t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); + t.true(mockRLInterface.question.firstCall.args[0].includes("continue"), + "Confirmation question should ask to continue"); + + // Check that cleanCache was called + t.is(cleanCacheStub.callCount, 1, "cleanCache should be called once"); + + // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); t.true(allOutput.includes("2 entries"), "Summary mentions entry count"); - t.true(allOutput.includes("23.0 MB"), "Summary mentions freed size"); + t.true(allOutput.includes("Success"), "Shows success message"); }); -test("Command definition is correct", (t) => { +test.serial("ui5 cache clean: user cancels", async (t) => { + const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, + mockRLInterface} = t.context; + + // Simulate existing cache items + getCacheInfoStub.resolves([ + {path: "framework/", size: 5 * 1024 * 1024, type: "directory"} + ]); + + // Mock user cancellation + mockRLInterface.question.callsFake((question, callback) => { + callback("n"); + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + // Check that confirmation was asked + t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); + + // Check that cleanCache was NOT called + t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called when user cancels"); + + // Check output + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); + t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); + t.false(allOutput.includes("Success"), "Should not show success message"); +}); + +test.serial("Command definition is correct", (t) => { // Import without esmock for structure check t.is(t.context.cache.command, "cache"); t.is(t.context.cache.describe, "Manage UI5 CLI cache"); t.is(typeof t.context.cache.builder, "function"); t.is(typeof t.context.cache.handler, "function"); }); + +test.serial("ui5 cache clean: accepts 'yes' as confirmation", async (t) => { + const {cache, argv, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context; + + getCacheInfoStub.resolves([ + {path: "framework/", size: 1024, type: "directory"} + ]); + + mockRLInterface.question.callsFake((question, callback) => { + callback("yes"); + }); + + cleanCacheStub.resolves({ + entries: [{path: "framework", type: "framework", size: 1024}], + totalSize: 1024, + totalCount: 1, + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(cleanCacheStub.callCount, 1, "cleanCache should be called with 'yes' confirmation"); +}); + +test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context; + + // Test with small bytes (B), KB, and GB sizes + getCacheInfoStub.resolves([ + {path: "small", size: 512, type: "directory"}, // < 1024 = B + {path: "medium", size: 50 * 1024, type: "directory"}, // KB + {path: "large", size: 2 * 1024 * 1024 * 1024, type: "directory"}, // GB + ]); + + mockRLInterface.question.callsFake((question, callback) => { + callback("y"); + }); + + cleanCacheStub.resolves({ + entries: [ + {path: "small", type: "directory", size: 512}, + {path: "medium", type: "directory", size: 50 * 1024}, + {path: "large", type: "directory", size: 2 * 1024 * 1024 * 1024}, + ], + totalSize: 2 * 1024 * 1024 * 1024 + 50 * 1024 + 512, + totalCount: 3, + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("512 B"), "Shows bytes format"); + t.true(allOutput.includes("50.0 KB"), "Shows KB format"); + t.true(allOutput.includes("2.0 GB"), "Shows GB format"); +}); + +test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => { + const {cache, argv, getCacheInfoStub} = t.context; + const originalEnv = process.env.UI5_DATA_DIR; + + try { + process.env.UI5_DATA_DIR = "/custom/ui5/path"; + + getCacheInfoStub.resolves([]); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called"); + t.true(getCacheInfoStub.firstCall.args[0].ui5DataDir.includes("custom/ui5/path"), + "Uses environment variable path"); + } finally { + if (originalEnv) { + process.env.UI5_DATA_DIR = originalEnv; + } else { + delete process.env.UI5_DATA_DIR; + } + } +}); + +test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async (t) => { + const {cache, argv, getCacheInfoStub, Configuration} = t.context; + const originalEnv = process.env.UI5_DATA_DIR; + + try { + delete process.env.UI5_DATA_DIR; + + Configuration.fromFile.resolves(new Configuration({ui5DataDir: "/config/path"})); + getCacheInfoStub.resolves([]); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called"); + } finally { + if (originalEnv) { + process.env.UI5_DATA_DIR = originalEnv; + } + } +}); diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index fe4fe2b8581..d40237caceb 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -576,6 +576,21 @@ export default class BuildCacheStorage { 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 count = this.#db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()?.count ?? 0; + if (count > 0) { + return true; + } + } + return false; + } /** * Closes the database connection */ diff --git a/packages/project/lib/build/cache/CacheCleanup.js b/packages/project/lib/build/cache/CacheCleanup.js index b5c929caa07..c9bcd4a7d2f 100644 --- a/packages/project/lib/build/cache/CacheCleanup.js +++ b/packages/project/lib/build/cache/CacheCleanup.js @@ -8,7 +8,7 @@ import BuildCacheStorage from "./BuildCacheStorage.js"; * @param {string} dirPath Absolute path to directory * @returns {Promise} Total size in bytes */ -async function getDirectorySize(dirPath) { +export async function getDirectorySize(dirPath) { let total = 0; let entries; try { @@ -75,6 +75,73 @@ async function cleanBuildCache(buildCacheDir) { return removed; } +/** + * Check what cache items exist and their sizes without removing them. + * + * @param {object} options + * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} List of cache items + */ +export async function getCacheInfo({ui5DataDir}) { + const items = []; + + // Check framework directory + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.access(frameworkDir); + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + items.push({ + path: "framework/", + size, + type: "directory" + }); + } + } catch { + // Directory doesn't exist, skip + } + + // Check buildCache directory + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + try { + await fs.access(buildCacheDir); + const versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + + let hasAnyRecords = false; + for (const versionDir of versionDirs) { + if (!versionDir.isDirectory()) { + continue; + } + + const dbDir = path.join(buildCacheDir, versionDir.name); + try { + const storage = new BuildCacheStorage(dbDir); + if (storage.hasRecords()) { + hasAnyRecords = true; + storage.close(); + break; + } + storage.close(); + } catch { + // Skip if database can't be opened + } + } + + if (hasAnyRecords) { + const size = await getDirectorySize(buildCacheDir); + items.push({ + path: "buildCache/ (database records)", + size, + type: "database" + }); + } + } catch { + // Directory doesn't exist, skip + } + + return items; +} + /** * Cleans cache directories for framework libraries and incremental build cache. * @@ -90,23 +157,30 @@ async function cleanBuildCache(buildCacheDir) { export async function cleanCache({ui5DataDir}) { const allRemoved = []; - // Clean entire framework directory (packages, cacache, staging, locks, etc.) - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.access(frameworkDir); - const size = await getDirectorySize(frameworkDir); - await fs.rm(frameworkDir, {recursive: true, force: true}); - allRemoved.push({ - path: "framework", - type: "framework", - size - }); - } catch { - // Framework directory doesn't exist or couldn't be removed + // Get info about what exists (reuses getCacheInfo to avoid duplication) + const items = await getCacheInfo({ui5DataDir}); + + // Remove framework if it exists + const frameworkItem = items.find((item) => item.path === "framework/"); + if (frameworkItem) { + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.rm(frameworkDir, {recursive: true, force: true}); + allRemoved.push({ + path: "framework", + type: "framework", + size: frameworkItem.size + }); + } catch { + // Framework directory couldn't be removed + } } - // Clean build cache (clears DB records, preserves files) - allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); + // Clean build cache if it exists + const buildCacheItem = items.find((item) => item.type === "database"); + if (buildCacheItem) { + allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); + } const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); return { From 0320c6f5a409ac2cb73dd5d66b44b594c708c294 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:56:25 +0300 Subject: [PATCH 06/18] refactor: Rename cacheVersionDir --- packages/project/lib/build/cache/CacheCleanup.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/project/lib/build/cache/CacheCleanup.js b/packages/project/lib/build/cache/CacheCleanup.js index c9bcd4a7d2f..700ec9eb51a 100644 --- a/packages/project/lib/build/cache/CacheCleanup.js +++ b/packages/project/lib/build/cache/CacheCleanup.js @@ -46,15 +46,15 @@ async function cleanBuildCache(buildCacheDir) { return removed; } - let versionDirs; + let cacheVersionDirs; try { - versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + cacheVersionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); } catch { return removed; } - for (const versionDir of versionDirs) { + for (const versionDir of cacheVersionDirs) { if (!versionDir.isDirectory()) { continue; } From b0c925241de729c80f4578fb7f72af96e9266f51 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 15:18:48 +0300 Subject: [PATCH 07/18] refactor: Restore location of CacheCleanup --- packages/cli/lib/cli/commands/cache.js | 2 +- packages/cli/test/lib/cli/commands/cache.js | 2 +- .../lib/{build => }/cache/CacheCleanup.js | 209 ++++++++++++------ packages/project/package.json | 2 +- .../lib/{build => }/cache/CacheCleanup.js | 4 +- 5 files changed, 142 insertions(+), 77 deletions(-) rename packages/project/lib/{build => }/cache/CacheCleanup.js (56%) rename packages/project/test/lib/{build => }/cache/CacheCleanup.js (96%) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index ab254ca546e..845d67119e3 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -5,7 +5,7 @@ import process from "node:process"; import readline from "node:readline"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; -import {cleanCache, getCacheInfo} from "@ui5/project/build/cache/CacheCleanup"; +import {cleanCache, getCacheInfo} from "@ui5/project/cache/CacheCleanup"; const cacheCommand = { command: "cache", diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index eff92cbd1f7..f3ec70381a7 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -36,7 +36,7 @@ test.beforeEach(async (t) => { t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, - "@ui5/project/build/cache/CacheCleanup": { + "@ui5/project/cache/CacheCleanup": { cleanCache: t.context.cleanCacheStub, getCacheInfo: t.context.getCacheInfoStub, }, diff --git a/packages/project/lib/build/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js similarity index 56% rename from packages/project/lib/build/cache/CacheCleanup.js rename to packages/project/lib/cache/CacheCleanup.js index 700ec9eb51a..c866537fc87 100644 --- a/packages/project/lib/build/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -1,6 +1,10 @@ import path from "node:path"; import fs from "node:fs/promises"; -import BuildCacheStorage from "./BuildCacheStorage.js"; +import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; + +// ======================================== +// SHARED UTILITIES +// ======================================== /** * Get the size of a directory tree recursively. @@ -8,7 +12,7 @@ import BuildCacheStorage from "./BuildCacheStorage.js"; * @param {string} dirPath Absolute path to directory * @returns {Promise} Total size in bytes */ -export async function getDirectorySize(dirPath) { +async function getDirectorySize(dirPath) { let total = 0; let entries; try { @@ -32,14 +36,123 @@ export async function getDirectorySize(dirPath) { return total; } +// ======================================== +// FRAMEWORK CACHE (ui5Framework namespace) +// Manages: framework/packages, framework/cacache, +// framework/staging, framework/locks, etc. +// ======================================== + +/** + * Check if framework cache exists and get its info. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number, type: string}|null>} Framework cache info or null + */ +async function getFrameworkCacheInfo(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.access(frameworkDir); + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + return { + path: "framework/", + size, + type: "directory" + }; + } + } catch { + // Directory doesn't exist + } + return null; +} + +/** + * Clean framework cache directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @param {{path: string, size: number, type: string}} frameworkInfo Framework cache info + * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + */ +async function cleanFrameworkCache(ui5DataDir, frameworkInfo) { + if (!frameworkInfo) { + return null; + } + + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.rm(frameworkDir, {recursive: true, force: true}); + return { + path: "framework", + type: "framework", + size: frameworkInfo.size + }; + } catch { + // Framework directory couldn't be removed + } + return null; +} + +// ======================================== +// BUILD CACHE (build/cache namespace) +// Manages: buildCache/v*/ SQLite databases +// ======================================== + /** - * Clean build cache directory by clearing all records from the SQLite database. + * Check if build cache exists and get its info. * - * @param {string} buildCacheDir Path to buildCache/ + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null + */ +async function getBuildCacheInfo(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + try { + await fs.access(buildCacheDir); + const versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + + let hasAnyRecords = false; + for (const versionDir of versionDirs) { + if (!versionDir.isDirectory()) { + continue; + } + + const dbDir = path.join(buildCacheDir, versionDir.name); + try { + const storage = new BuildCacheStorage(dbDir); + if (storage.hasRecords()) { + hasAnyRecords = true; + storage.close(); + break; + } + storage.close(); + } catch { + // Skip if database can't be opened + } + } + + if (hasAnyRecords) { + const size = await getDirectorySize(buildCacheDir); + return { + path: "buildCache/ (database records)", + size, + type: "database" + }; + } + } catch { + // Directory doesn't exist + } + return null; +} + +/** + * Clean build cache by clearing all records from SQLite databases. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise>} Removed entries */ -async function cleanBuildCache(buildCacheDir) { +async function cleanBuildCache(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); const removed = []; + try { await fs.access(buildCacheDir); } catch { @@ -53,7 +166,6 @@ async function cleanBuildCache(buildCacheDir) { return removed; } - for (const versionDir of cacheVersionDirs) { if (!versionDir.isDirectory()) { continue; @@ -75,6 +187,10 @@ async function cleanBuildCache(buildCacheDir) { return removed; } +// ======================================== +// PUBLIC API - Orchestrates both caches +// ======================================== + /** * Check what cache items exist and their sizes without removing them. * @@ -85,58 +201,16 @@ async function cleanBuildCache(buildCacheDir) { export async function getCacheInfo({ui5DataDir}) { const items = []; - // Check framework directory - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.access(frameworkDir); - const size = await getDirectorySize(frameworkDir); - if (size > 0) { - items.push({ - path: "framework/", - size, - type: "directory" - }); - } - } catch { - // Directory doesn't exist, skip + // Check framework cache + const frameworkInfo = await getFrameworkCacheInfo(ui5DataDir); + if (frameworkInfo) { + items.push(frameworkInfo); } - // Check buildCache directory - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - try { - await fs.access(buildCacheDir); - const versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); - - let hasAnyRecords = false; - for (const versionDir of versionDirs) { - if (!versionDir.isDirectory()) { - continue; - } - - const dbDir = path.join(buildCacheDir, versionDir.name); - try { - const storage = new BuildCacheStorage(dbDir); - if (storage.hasRecords()) { - hasAnyRecords = true; - storage.close(); - break; - } - storage.close(); - } catch { - // Skip if database can't be opened - } - } - - if (hasAnyRecords) { - const size = await getDirectorySize(buildCacheDir); - items.push({ - path: "buildCache/ (database records)", - size, - type: "database" - }); - } - } catch { - // Directory doesn't exist, skip + // Check build cache + const buildInfo = await getBuildCacheInfo(ui5DataDir); + if (buildInfo) { + items.push(buildInfo); } return items; @@ -157,29 +231,20 @@ export async function getCacheInfo({ui5DataDir}) { export async function cleanCache({ui5DataDir}) { const allRemoved = []; - // Get info about what exists (reuses getCacheInfo to avoid duplication) + // Get info about what exists const items = await getCacheInfo({ui5DataDir}); - // Remove framework if it exists + // Clean framework cache const frameworkItem = items.find((item) => item.path === "framework/"); - if (frameworkItem) { - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.rm(frameworkDir, {recursive: true, force: true}); - allRemoved.push({ - path: "framework", - type: "framework", - size: frameworkItem.size - }); - } catch { - // Framework directory couldn't be removed - } + const frameworkResult = await cleanFrameworkCache(ui5DataDir, frameworkItem); + if (frameworkResult) { + allRemoved.push(frameworkResult); } - // Clean build cache if it exists + // Clean build cache const buildCacheItem = items.find((item) => item.type === "database"); if (buildCacheItem) { - allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); + allRemoved.push(...await cleanBuildCache(ui5DataDir)); } const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); diff --git a/packages/project/package.json b/packages/project/package.json index ee1034e6c7f..3fead1c7555 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -31,7 +31,7 @@ "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", "./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", - "./build/cache/CacheCleanup": "./lib/build/cache/CacheCleanup.js", + "./cache/CacheCleanup": "./lib/cache/CacheCleanup.js", "./package.json": "./package.json" }, "engines": { diff --git a/packages/project/test/lib/build/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js similarity index 96% rename from packages/project/test/lib/build/cache/CacheCleanup.js rename to packages/project/test/lib/cache/CacheCleanup.js index 62daad3aa15..7340b807cea 100644 --- a/packages/project/test/lib/build/cache/CacheCleanup.js +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -2,9 +2,9 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import {rimraf} from "rimraf"; -import {cleanCache} from "../../../../lib/build/cache/CacheCleanup.js"; +import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "CacheCleanup"); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); test.after.always(async () => { await rimraf(TEST_DIR).catch(() => {}); From dc168dc508de7716b03bb5c6f16de088e2732fc3 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 15:50:10 +0300 Subject: [PATCH 08/18] fix: Clean only current cache version --- .../project/lib/build/cache/CacheManager.js | 2 +- packages/project/lib/cache/CacheCleanup.js | 91 ++++++++----------- 2 files changed, 40 insertions(+), 53 deletions(-) diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index c1e057427b3..ebcb52c32f3 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -10,7 +10,7 @@ const log = getLogger("build:cache:CacheManager"); const cacheManagerInstances = new Map(); // Cache version for compatibility management -const CACHE_VERSION = "v0_7"; +export const CACHE_VERSION = "v0_7"; /** * Manages persistence for the build cache using a unified SQLite-backed storage diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js index c866537fc87..67e942c317c 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -1,6 +1,7 @@ import path from "node:path"; import fs from "node:fs/promises"; import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; +import {CACHE_VERSION} from "../build/cache/CacheManager.js"; // ======================================== // SHARED UTILITIES @@ -99,89 +100,75 @@ async function cleanFrameworkCache(ui5DataDir, frameworkInfo) { /** * Check if build cache exists and get its info. + * Only checks the current known cache version to avoid processing unknown future versions. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null */ async function getBuildCacheInfo(ui5DataDir) { const buildCacheDir = path.join(ui5DataDir, "buildCache"); - try { - await fs.access(buildCacheDir); - const versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); - let hasAnyRecords = false; - for (const versionDir of versionDirs) { - if (!versionDir.isDirectory()) { - continue; - } + try { + await fs.access(dbDir); + } catch { + // Current version directory doesn't exist + return null; + } - const dbDir = path.join(buildCacheDir, versionDir.name); - try { - const storage = new BuildCacheStorage(dbDir); - if (storage.hasRecords()) { - hasAnyRecords = true; - storage.close(); - break; - } - storage.close(); - } catch { - // Skip if database can't be opened + try { + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const size = await getDirectorySize(buildCacheDir); + return { + path: `buildCache/${CACHE_VERSION} (database records)`, + size, + type: "database" + }; } - } - - if (hasAnyRecords) { - const size = await getDirectorySize(buildCacheDir); - return { - path: "buildCache/ (database records)", - size, - type: "database" - }; + } finally { + storage.close(); } } catch { - // Directory doesn't exist + // Skip if database can't be opened } return null; } /** - * Clean build cache by clearing all records from SQLite databases. + * Clean build cache by clearing all records from SQLite database. + * Only cleans the current known cache version to avoid processing unknown future versions. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise>} Removed entries */ async function cleanBuildCache(ui5DataDir) { const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); const removed = []; try { - await fs.access(buildCacheDir); + await fs.access(dbDir); } catch { + // Current version directory doesn't exist return removed; } - let cacheVersionDirs; try { - cacheVersionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); - } catch { - return removed; - } - - for (const versionDir of cacheVersionDirs) { - if (!versionDir.isDirectory()) { - continue; - } - - const dbDir = path.join(buildCacheDir, versionDir.name); - const storage = new BuildCacheStorage(dbDir); - const freedSize = storage.clearAllRecords(); - storage.close(); - - removed.push({ - path: `buildCache/${versionDir.name}`, - type: "buildCache", - size: freedSize, - }); + try { + const freedSize = storage.clearAllRecords(); + removed.push({ + path: `buildCache/${CACHE_VERSION}`, + type: "buildCache", + size: freedSize, + }); + } finally { + storage.close(); + } + } catch { + // Skip if database can't be cleared } return removed; From becc3a8ebd4342d114e38dd7522aab1430998538 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 16:01:30 +0300 Subject: [PATCH 09/18] refactor: Simplify CacheCleanup --- packages/project/lib/cache/CacheCleanup.js | 189 +++++---------------- 1 file changed, 45 insertions(+), 144 deletions(-) diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js index 67e942c317c..7902ccea9eb 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -3,10 +3,6 @@ import fs from "node:fs/promises"; import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; import {CACHE_VERSION} from "../build/cache/CacheManager.js"; -// ======================================== -// SHARED UTILITIES -// ======================================== - /** * Get the size of a directory tree recursively. * @@ -37,167 +33,52 @@ async function getDirectorySize(dirPath) { return total; } -// ======================================== -// FRAMEWORK CACHE (ui5Framework namespace) -// Manages: framework/packages, framework/cacache, -// framework/staging, framework/locks, etc. -// ======================================== - /** - * Check if framework cache exists and get its info. + * Check what cache items exist and their sizes without removing them. * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number, type: string}|null>} Framework cache info or null + * @param {object} options + * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} List of cache items */ -async function getFrameworkCacheInfo(ui5DataDir) { +export async function getCacheInfo({ui5DataDir}) { + const items = []; + + // Check framework cache const frameworkDir = path.join(ui5DataDir, "framework"); try { await fs.access(frameworkDir); const size = await getDirectorySize(frameworkDir); if (size > 0) { - return { + items.push({ path: "framework/", size, type: "directory" - }; + }); } } catch { // Directory doesn't exist } - return null; -} -/** - * Clean framework cache directory. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @param {{path: string, size: number, type: string}} frameworkInfo Framework cache info - * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null - */ -async function cleanFrameworkCache(ui5DataDir, frameworkInfo) { - if (!frameworkInfo) { - return null; - } - - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.rm(frameworkDir, {recursive: true, force: true}); - return { - path: "framework", - type: "framework", - size: frameworkInfo.size - }; - } catch { - // Framework directory couldn't be removed - } - return null; -} - -// ======================================== -// BUILD CACHE (build/cache namespace) -// Manages: buildCache/v*/ SQLite databases -// ======================================== - -/** - * Check if build cache exists and get its info. - * Only checks the current known cache version to avoid processing unknown future versions. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null - */ -async function getBuildCacheInfo(ui5DataDir) { + // Check build cache (only current version) const buildCacheDir = path.join(ui5DataDir, "buildCache"); const dbDir = path.join(buildCacheDir, CACHE_VERSION); - try { await fs.access(dbDir); - } catch { - // Current version directory doesn't exist - return null; - } - - try { const storage = new BuildCacheStorage(dbDir); try { if (storage.hasRecords()) { const size = await getDirectorySize(buildCacheDir); - return { + items.push({ path: `buildCache/${CACHE_VERSION} (database records)`, size, type: "database" - }; + }); } } finally { storage.close(); } } catch { - // Skip if database can't be opened - } - return null; -} - -/** - * Clean build cache by clearing all records from SQLite database. - * Only cleans the current known cache version to avoid processing unknown future versions. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise>} Removed entries - */ -async function cleanBuildCache(ui5DataDir) { - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - const dbDir = path.join(buildCacheDir, CACHE_VERSION); - const removed = []; - - try { - await fs.access(dbDir); - } catch { - // Current version directory doesn't exist - return removed; - } - - try { - const storage = new BuildCacheStorage(dbDir); - try { - const freedSize = storage.clearAllRecords(); - removed.push({ - path: `buildCache/${CACHE_VERSION}`, - type: "buildCache", - size: freedSize, - }); - } finally { - storage.close(); - } - } catch { - // Skip if database can't be cleared - } - - return removed; -} - -// ======================================== -// PUBLIC API - Orchestrates both caches -// ======================================== - -/** - * Check what cache items exist and their sizes without removing them. - * - * @param {object} options - * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise>} List of cache items - */ -export async function getCacheInfo({ui5DataDir}) { - const items = []; - - // Check framework cache - const frameworkInfo = await getFrameworkCacheInfo(ui5DataDir); - if (frameworkInfo) { - items.push(frameworkInfo); - } - - // Check build cache - const buildInfo = await getBuildCacheInfo(ui5DataDir); - if (buildInfo) { - items.push(buildInfo); + // Skip if database can't be opened or doesn't exist } return items; @@ -218,20 +99,40 @@ export async function getCacheInfo({ui5DataDir}) { export async function cleanCache({ui5DataDir}) { const allRemoved = []; - // Get info about what exists - const items = await getCacheInfo({ui5DataDir}); - // Clean framework cache - const frameworkItem = items.find((item) => item.path === "framework/"); - const frameworkResult = await cleanFrameworkCache(ui5DataDir, frameworkItem); - if (frameworkResult) { - allRemoved.push(frameworkResult); + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + await fs.rm(frameworkDir, {recursive: true, force: true}); + allRemoved.push({ + path: "framework", + type: "framework", + size + }); + } + } catch { + // Directory doesn't exist or couldn't be removed } - // Clean build cache - const buildCacheItem = items.find((item) => item.type === "database"); - if (buildCacheItem) { - allRemoved.push(...await cleanBuildCache(ui5DataDir)); + // Clean build cache (only current version) + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); + try { + await fs.access(dbDir); + const storage = new BuildCacheStorage(dbDir); + try { + const freedSize = storage.clearAllRecords(); + allRemoved.push({ + path: `buildCache/${CACHE_VERSION}`, + type: "buildCache", + size: freedSize + }); + } finally { + storage.close(); + } + } catch { + // Database doesn't exist or couldn't be cleared } const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); From c9588915e4719679e9e6f9450c843cfe63e9a086 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 16:13:13 +0300 Subject: [PATCH 10/18] test: Improve coverage --- .../project/test/lib/cache/CacheCleanup.js | 210 +++++++++++++++--- 1 file changed, 184 insertions(+), 26 deletions(-) diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js index 7340b807cea..f9b4d784d20 100644 --- a/packages/project/test/lib/cache/CacheCleanup.js +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -2,7 +2,7 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import {rimraf} from "rimraf"; -import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; +import {cleanCache, getCacheInfo} from "../../../lib/cache/CacheCleanup.js"; const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); @@ -10,29 +10,12 @@ test.after.always(async () => { await rimraf(TEST_DIR).catch(() => {}); }); -/** - * Create a unique test directory for each test. - * - * @param {object} t AVA test context - * @returns {string} Path to the ui5DataDir fixture - */ function createTestDir(t) { const dir = path.join(TEST_DIR, `test-${Date.now()}-${Math.random().toString(36).slice(2)}`); t.context.ui5DataDir = dir; return dir; } -/** - * Create a framework package fixture. - * - * @param {string} ui5DataDir Base data directory - * @param {string} scope Package scope (e.g., "@openui5") - * @param {string} name Package name (e.g., "sap.ui.core") - * @param {string} version Version string - * @param {object} [options] - * @param {Date} [options.mtime] Custom mtime for the package file - * @returns {Promise} - */ async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { const pkgDir = path.join(ui5DataDir, "framework", "packages", scope, name, version); await fs.mkdir(pkgDir, {recursive: true}); @@ -43,7 +26,7 @@ async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { } } -// ===== cleanCache: empty/nonexistent dir ===== +// ===== cleanCache tests ===== test("cleanCache: returns empty result for nonexistent directory", async (t) => { const result = await cleanCache({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); @@ -52,8 +35,6 @@ test("cleanCache: returns empty result for nonexistent directory", async (t) => t.deepEqual(result.entries, []); }); -// ===== cleanCache: clean all ===== - test("cleanCache: clean all removes framework packages", async (t) => { const ui5DataDir = createTestDir(t); await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); @@ -67,14 +48,11 @@ test("cleanCache: clean all removes framework packages", async (t) => { t.is(frameworkEntries[0].path, "framework"); }); -// ===== cleanCache: build cache (full clean) ===== - test("cleanCache: clean all clears buildCache database", async (t) => { const ui5DataDir = createTestDir(t); const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); await fs.mkdir(buildCacheDir, {recursive: true}); - // Create a real SQLite database with tables and some data const {DatabaseSync} = await import("node:sqlite"); const dbPath = path.join(buildCacheDir, "cache.db"); const db = new DatabaseSync(dbPath); @@ -95,11 +73,9 @@ test("cleanCache: clean all clears buildCache database", async (t) => { const buildCacheEntry = result.entries.find((e) => e.type === "buildCache"); t.truthy(buildCacheEntry); - // Verify directory and DB file still exist await fs.access(buildCacheDir); await fs.access(dbPath); - // Verify tables are empty const dbAfter = new DatabaseSync(dbPath); const contentCount = dbAfter.prepare("SELECT COUNT(*) as count FROM content").get().count; const indexCount = dbAfter.prepare("SELECT COUNT(*) as count FROM index_cache").get().count; @@ -107,3 +83,185 @@ test("cleanCache: clean all clears buildCache database", async (t) => { t.is(indexCount, 0); dbAfter.close(); }); + +test("cleanCache: skips empty framework directory", async (t) => { + const ui5DataDir = createTestDir(t); + const frameworkDir = path.join(ui5DataDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + + const result = await cleanCache({ui5DataDir}); + + t.is(result.totalCount, 0); + const frameworkEntries = result.entries.filter((e) => e.type === "framework"); + t.is(frameworkEntries.length, 0); +}); + +test("cleanCache: cleans both framework and build cache", async (t) => { + const ui5DataDir = createTestDir(t); + + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT); + CREATE TABLE index_cache (project_id TEXT); + CREATE TABLE stage_metadata (project_id TEXT); + CREATE TABLE task_metadata (project_id TEXT); + CREATE TABLE result_metadata (project_id TEXT); + `); + db.exec("INSERT INTO content VALUES ('test')"); + db.close(); + + const result = await cleanCache({ui5DataDir}); + + t.true(result.totalCount >= 1); // At least framework + t.truthy(result.entries.find((e) => e.type === "framework")); + t.true(result.totalSize > 0); + // Build cache may also be cleaned + if (result.totalCount === 2) { + t.truthy(result.entries.find((e) => e.type === "buildCache")); + } +}); + +test("cleanCache: handles corrupted database gracefully", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + await fs.writeFile(path.join(buildCacheDir, "cache.db"), "not a valid database"); + + const result = await cleanCache({ui5DataDir}); + + t.pass(); + const buildEntries = result.entries.filter((e) => e.type === "buildCache"); + t.is(buildEntries.length, 0); +}); + +// ===== getCacheInfo tests ===== + +test("getCacheInfo: returns empty array for nonexistent directory", async (t) => { + const items = await getCacheInfo({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); + t.deepEqual(items, []); +}); + +test("getCacheInfo: detects framework cache with size", async (t) => { + const ui5DataDir = createTestDir(t); + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + + const items = await getCacheInfo({ui5DataDir}); + + const frameworkItem = items.find((item) => item.path === "framework/"); + t.truthy(frameworkItem); + t.true(frameworkItem.size > 0); + t.is(frameworkItem.type, "directory"); +}); + +test("getCacheInfo: skips empty framework directory", async (t) => { + const ui5DataDir = createTestDir(t); + const frameworkDir = path.join(ui5DataDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + + const items = await getCacheInfo({ui5DataDir}); + + const frameworkItem = items.find((item) => item.path === "framework/"); + t.falsy(frameworkItem); +}); + +test("getCacheInfo: detects build cache with records", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); + CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); + CREATE TABLE stage_metadata + (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); + CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); + CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); + `); + db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); + db.close(); + + const items = await getCacheInfo({ui5DataDir}); + + const buildItem = items.find((item) => item.type === "database"); + t.truthy(buildItem); + t.is(buildItem.path, "buildCache/v0_7 (database records)"); + t.true(buildItem.size > 0); +}); + +test("getCacheInfo: skips build cache with no records", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); + CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); + CREATE TABLE stage_metadata + (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); + CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); + CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); + `); + db.close(); + + const items = await getCacheInfo({ui5DataDir}); + + const buildItem = items.find((item) => item.type === "database"); + t.falsy(buildItem); +}); + +test("getCacheInfo: handles corrupted database gracefully", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + await fs.writeFile(path.join(buildCacheDir, "cache.db"), "not a valid database"); + + const items = await getCacheInfo({ui5DataDir}); + + t.pass(); + const buildItem = items.find((item) => item.type === "database"); + t.falsy(buildItem); +}); + +test("getCacheInfo: detects both framework and build cache", async (t) => { + const ui5DataDir = createTestDir(t); + + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT); + CREATE TABLE index_cache (project_id TEXT); + CREATE TABLE stage_metadata (project_id TEXT); + CREATE TABLE task_metadata (project_id TEXT); + CREATE TABLE result_metadata (project_id TEXT); + `); + db.exec("INSERT INTO content VALUES ('test')"); + db.close(); + + const items = await getCacheInfo({ui5DataDir}); + + t.true(items.length >= 1); // At least framework + t.truthy(items.find((item) => item.path === "framework/")); + // Build cache may also be detected + if (items.length === 2) { + t.truthy(items.find((item) => item.type === "database")); + } +}); From 36fd376f43f42133490c8a2918b34fb7ca095354 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 18:36:37 +0300 Subject: [PATCH 11/18] refactor: CLI package orchestrates cache cleanup Provide common interface for cache cleanup, but distribute the real cleanup among the respective destinations --- packages/cli/lib/cli/commands/cache.js | 34 ++- packages/cli/test/lib/cli/commands/cache.js | 120 ++++---- .../lib/build/cache/BuildCacheStorage.js | 11 + .../project/lib/build/cache/CacheManager.js | 64 ++++- packages/project/lib/cache/CacheCleanup.js | 144 ---------- packages/project/lib/ui5Framework/cache.js | 80 ++++++ packages/project/package.json | 3 +- .../test/lib/build/cache/CacheManager.js | 76 +++++ .../project/test/lib/cache/CacheCleanup.js | 267 ------------------ packages/project/test/lib/package-exports.js | 2 +- .../project/test/lib/ui5framework/cache.js | 101 +++++++ 11 files changed, 417 insertions(+), 485 deletions(-) delete mode 100644 packages/project/lib/cache/CacheCleanup.js create mode 100644 packages/project/lib/ui5Framework/cache.js delete mode 100644 packages/project/test/lib/cache/CacheCleanup.js create mode 100644 packages/project/test/lib/ui5framework/cache.js diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 845d67119e3..87108617141 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -5,7 +5,8 @@ import process from "node:process"; import readline from "node:readline"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; -import {cleanCache, getCacheInfo} from "@ui5/project/cache/CacheCleanup"; +import * as frameworkCache from "@ui5/project/ui5Framework/cache"; +import CacheManager from "@ui5/project/build/cache/CacheManager"; const cacheCommand = { command: "cache", @@ -78,8 +79,16 @@ async function handleCache() { ui5DataDir = path.join(os.homedir(), ".ui5"); } - // Check what items exist before cleaning - const items = await getCacheInfo({ui5DataDir}); + // Check what items exist before cleaning (orchestrate both domains) + const items = []; + const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); + if (frameworkInfo) { + items.push(frameworkInfo); + } + const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); + if (buildInfo) { + items.push(buildInfo); + } if (items.length === 0) { process.stderr.write("Nothing to clean\n"); @@ -103,18 +112,27 @@ async function handleCache() { return; } - // Perform the actual cleanup - const result = await cleanCache({ui5DataDir}); + // Perform the actual cleanup (orchestrate both domains) + const removed = []; + const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); + if (frameworkResult) { + removed.push(frameworkResult); + } + const buildResult = await CacheManager.cleanCache(ui5DataDir); + if (buildResult) { + removed.push(buildResult); + } process.stderr.write("\n"); - for (const entry of result.entries) { + for (const entry of removed) { const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : ""; process.stderr.write(`${chalk.green("✓")} Removed ${chalk.bold(entry.path)}${sizeStr}\n`); } + const totalRemoved = removed.reduce((sum, entry) => sum + entry.size, 0); process.stderr.write( - `\n${chalk.green("Success:")} Cleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` + - (result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n" + `\n${chalk.green("Success:")} Cleaned ${removed.length} ${removed.length === 1 ? "entry" : "entries"}` + + (totalRemoved > 0 ? `, freed ${formatSize(totalRemoved)}` : "") + "\n" ); } diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index f3ec70381a7..06ab7e9d61b 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -23,8 +23,10 @@ test.beforeEach(async (t) => { t.context.Configuration = Configuration; sinon.stub(Configuration, "fromFile").resolves(new Configuration({})); - t.context.cleanCacheStub = sinon.stub(); - t.context.getCacheInfoStub = sinon.stub(); + t.context.frameworkCacheGetCacheInfo = sinon.stub(); + t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.buildCacheGetCacheInfo = sinon.stub(); + t.context.buildCacheCleanCache = sinon.stub(); // Mock readline to simulate user confirmation const mockRLInterface = { @@ -36,9 +38,15 @@ test.beforeEach(async (t) => { t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, - "@ui5/project/cache/CacheCleanup": { - cleanCache: t.context.cleanCacheStub, - getCacheInfo: t.context.getCacheInfoStub, + "@ui5/project/ui5Framework/cache": { + getCacheInfo: t.context.frameworkCacheGetCacheInfo, + cleanCache: t.context.frameworkCacheCleanCache + }, + "@ui5/project/build/cache/CacheManager": { + default: class { + static getCacheInfo = t.context.buildCacheGetCacheInfo; + static cleanCache = t.context.buildCacheCleanCache; + } }, "node:readline": { createInterface: t.context.readlineCreateInterfaceStub, @@ -67,41 +75,38 @@ test("Command builder", async (t) => { }); test.serial("ui5 cache clean: nothing to clean", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub} = t.context; + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; // Simulate no cache items - getCacheInfoStub.resolves([]); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); argv["_"] = ["cache", "clean"]; await cache.handler(argv); t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n"); - t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called"); + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called"); }); test.serial("ui5 cache clean: removes entries and reports", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, - mockRLInterface} = t.context; + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; // Simulate existing cache items - getCacheInfoStub.resolves([ - {path: "framework/", size: 15 * 1024 * 1024, type: "directory"}, - {path: "buildCache/ (database records)", size: 8 * 1024 * 1024, type: "database"}, - ]); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves({ + path: "buildCache/v0_7 (database records)", size: 8 * 1024 * 1024, type: "database" + }); // Mock user confirmation mockRLInterface.question.callsFake((question, callback) => { callback("y"); }); - cleanCacheStub.resolves({ - entries: [ - {path: "framework", type: "framework", size: 15 * 1024 * 1024}, - {path: "buildCache", type: "buildCache", size: 8 * 1024 * 1024}, - ], - totalSize: 23 * 1024 * 1024, - totalCount: 2, - }); + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 15 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 8 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -112,7 +117,8 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { "Confirmation question should ask to continue"); // Check that cleanCache was called - t.is(cleanCacheStub.callCount, 1, "cleanCache should be called once"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called once"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called once"); // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); @@ -122,13 +128,12 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { }); test.serial("ui5 cache clean: user cancels", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, - mockRLInterface} = t.context; + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; // Simulate existing cache items - getCacheInfoStub.resolves([ - {path: "framework/", size: 5 * 1024 * 1024, type: "directory"} - ]); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); // Mock user cancellation mockRLInterface.question.callsFake((question, callback) => { @@ -141,8 +146,9 @@ test.serial("ui5 cache clean: user cancels", async (t) => { // Check that confirmation was asked t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); - // Check that cleanCache was NOT called - t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called when user cancels"); + // Check that cleanup was NOT called + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called when user cancels"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when user cancels"); // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); @@ -160,51 +166,38 @@ test.serial("Command definition is correct", (t) => { }); test.serial("ui5 cache clean: accepts 'yes' as confirmation", async (t) => { - const {cache, argv, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context; + const {cache, argv, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, mockRLInterface} = t.context; - getCacheInfoStub.resolves([ - {path: "framework/", size: 1024, type: "directory"} - ]); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); mockRLInterface.question.callsFake((question, callback) => { callback("yes"); }); - cleanCacheStub.resolves({ - entries: [{path: "framework", type: "framework", size: 1024}], - totalSize: 1024, - totalCount: 1, - }); + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(cleanCacheStub.callCount, 1, "cleanCache should be called with 'yes' confirmation"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called with 'yes' confirmation"); }); test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context; + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; // Test with small bytes (B), KB, and GB sizes - getCacheInfoStub.resolves([ - {path: "small", size: 512, type: "directory"}, // < 1024 = B - {path: "medium", size: 50 * 1024, type: "directory"}, // KB - {path: "large", size: 2 * 1024 * 1024 * 1024, type: "directory"}, // GB - ]); + frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); // < 1024 = B + buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); // KB mockRLInterface.question.callsFake((question, callback) => { callback("y"); }); - cleanCacheStub.resolves({ - entries: [ - {path: "small", type: "directory", size: 512}, - {path: "medium", type: "directory", size: 50 * 1024}, - {path: "large", type: "directory", size: 2 * 1024 * 1024 * 1024}, - ], - totalSize: 2 * 1024 * 1024 * 1024 + 50 * 1024 + 512, - totalCount: 3, - }); + frameworkCacheCleanCache.resolves({path: "small", type: "directory", size: 512}); + buildCacheCleanCache.resolves({path: "medium", type: "database", size: 50 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -212,23 +205,23 @@ test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes("512 B"), "Shows bytes format"); t.true(allOutput.includes("50.0 KB"), "Shows KB format"); - t.true(allOutput.includes("2.0 GB"), "Shows GB format"); }); test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => { - const {cache, argv, getCacheInfoStub} = t.context; + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; const originalEnv = process.env.UI5_DATA_DIR; try { process.env.UI5_DATA_DIR = "/custom/ui5/path"; - getCacheInfoStub.resolves([]); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called"); - t.true(getCacheInfoStub.firstCall.args[0].ui5DataDir.includes("custom/ui5/path"), + t.is(frameworkCacheGetCacheInfo.callCount, 1, "frameworkCache.getCacheInfo called"); + t.true(frameworkCacheGetCacheInfo.firstCall.args[0].includes("custom/ui5/path"), "Uses environment variable path"); } finally { if (originalEnv) { @@ -240,19 +233,20 @@ test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => }); test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async (t) => { - const {cache, argv, getCacheInfoStub, Configuration} = t.context; + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, Configuration} = t.context; const originalEnv = process.env.UI5_DATA_DIR; try { delete process.env.UI5_DATA_DIR; Configuration.fromFile.resolves(new Configuration({ui5DataDir: "/config/path"})); - getCacheInfoStub.resolves([]); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called"); + t.is(frameworkCacheGetCacheInfo.callCount, 1, "frameworkCache.getCacheInfo called"); } finally { if (originalEnv) { process.env.UI5_DATA_DIR = originalEnv; diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index d40237caceb..0689e32bdb7 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -604,4 +604,15 @@ export default class BuildCacheStorage { this.#db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); this.#db.close(); } + + /** + * Get the total size of the database file + * + * @returns {number} Database size in bytes + */ + getDatabaseSize() { + const pageCount = this.#db.prepare("PRAGMA page_count").get().page_count; + const pageSize = this.#db.prepare("PRAGMA page_size").get().page_size; + return pageCount * pageSize; + } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index ebcb52c32f3..9ecf58b8d7d 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -10,7 +10,7 @@ const log = getLogger("build:cache:CacheManager"); const cacheManagerInstances = new Map(); // Cache version for compatibility management -export const CACHE_VERSION = "v0_7"; +const CACHE_VERSION = "v0_7"; /** * Manages persistence for the build cache using a unified SQLite-backed storage @@ -384,4 +384,66 @@ export default class CacheManager { cacheManagerInstances.delete(this.#cacheDir); } } + + /** + * 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, type: string}|null>} Build cache info or null + */ + static async getCacheInfo(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); + + try { + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const size = storage.getDatabaseSize(); + return { + path: `buildCache/${CACHE_VERSION}`, + size, + type: "database" + }; + } + } finally { + storage.close(); + } + } catch { + // Skip if database can't be opened + } + 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, type: string, size: number}|null>} Removal result or null + */ + static async cleanCache(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); + + try { + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const freedSize = storage.clearAllRecords(); + return { + path: `buildCache/${CACHE_VERSION}`, + type: "buildCache", + size: freedSize + }; + } + } finally { + storage.close(); + } + } catch { + // Skip if database can't be cleared + } + return null; + } } diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js deleted file mode 100644 index 7902ccea9eb..00000000000 --- a/packages/project/lib/cache/CacheCleanup.js +++ /dev/null @@ -1,144 +0,0 @@ -import path from "node:path"; -import fs from "node:fs/promises"; -import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; -import {CACHE_VERSION} from "../build/cache/CacheManager.js"; - -/** - * Get the size of a directory tree recursively. - * - * @param {string} dirPath Absolute path to directory - * @returns {Promise} Total size in bytes - */ -async function getDirectorySize(dirPath) { - let total = 0; - let entries; - try { - entries = await fs.readdir(dirPath, {withFileTypes: true}); - } catch { - return 0; - } - for (const entry of entries) { - const entryPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - total += await getDirectorySize(entryPath); - } else { - try { - const stat = await fs.stat(entryPath); - total += stat.size; - } catch { - // Skip inaccessible files - } - } - } - return total; -} - -/** - * Check what cache items exist and their sizes without removing them. - * - * @param {object} options - * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise>} List of cache items - */ -export async function getCacheInfo({ui5DataDir}) { - const items = []; - - // Check framework cache - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.access(frameworkDir); - const size = await getDirectorySize(frameworkDir); - if (size > 0) { - items.push({ - path: "framework/", - size, - type: "directory" - }); - } - } catch { - // Directory doesn't exist - } - - // Check build cache (only current version) - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - const dbDir = path.join(buildCacheDir, CACHE_VERSION); - try { - await fs.access(dbDir); - const storage = new BuildCacheStorage(dbDir); - try { - if (storage.hasRecords()) { - const size = await getDirectorySize(buildCacheDir); - items.push({ - path: `buildCache/${CACHE_VERSION} (database records)`, - size, - type: "database" - }); - } - } finally { - storage.close(); - } - } catch { - // Skip if database can't be opened or doesn't exist - } - - return items; -} - -/** - * Cleans cache directories for framework libraries and incremental build cache. - * - * Removes: - * - framework/ directory: All UI5 framework libraries, download cache, staging files, and locks - * - buildCache/ entries: Clears database records (preserves database files) - * - * @param {object} options - * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{entries: Array<{path: string, type: string, size: number}>, - * totalSize: number, totalCount: number}>} - */ -export async function cleanCache({ui5DataDir}) { - const allRemoved = []; - - // Clean framework cache - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - const size = await getDirectorySize(frameworkDir); - if (size > 0) { - await fs.rm(frameworkDir, {recursive: true, force: true}); - allRemoved.push({ - path: "framework", - type: "framework", - size - }); - } - } catch { - // Directory doesn't exist or couldn't be removed - } - - // Clean build cache (only current version) - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - const dbDir = path.join(buildCacheDir, CACHE_VERSION); - try { - await fs.access(dbDir); - const storage = new BuildCacheStorage(dbDir); - try { - const freedSize = storage.clearAllRecords(); - allRemoved.push({ - path: `buildCache/${CACHE_VERSION}`, - type: "buildCache", - size: freedSize - }); - } finally { - storage.close(); - } - } catch { - // Database doesn't exist or couldn't be cleared - } - - const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); - return { - entries: allRemoved, - totalSize, - totalCount: allRemoved.length, - }; -} diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js new file mode 100644 index 00000000000..9d3b19b7448 --- /dev/null +++ b/packages/project/lib/ui5Framework/cache.js @@ -0,0 +1,80 @@ +import path from "node:path"; +import fs from "node:fs/promises"; + +/** + * Get the size of a directory tree recursively. + * + * @param {string} dirPath Absolute path to directory + * @returns {Promise} Total size in bytes + */ +async function getDirectorySize(dirPath) { + let total = 0; + let entries; + try { + entries = await fs.readdir(dirPath, {withFileTypes: true}); + } catch { + return 0; + } + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + total += await getDirectorySize(entryPath); + } else { + try { + const stat = await fs.stat(entryPath); + total += stat.size; + } catch { + // Skip inaccessible files + } + } + } + return total; +} + +/** + * Get framework cache info. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number, type: string}|null>} Framework cache info or null + */ +export async function getCacheInfo(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.access(frameworkDir); + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + return { + path: "framework/", + size, + type: "directory" + }; + } + } catch { + // Directory doesn't exist + } + return null; +} + +/** + * Clean framework cache directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + */ +export async function cleanCache(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + await fs.rm(frameworkDir, {recursive: true, force: true}); + return { + path: "framework", + type: "framework", + size + }; + } + } catch { + // Directory doesn't exist or couldn't be removed + } + return null; +} diff --git a/packages/project/package.json b/packages/project/package.json index 3fead1c7555..2600710b8d2 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -20,18 +20,19 @@ "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", "./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", - "./cache/CacheCleanup": "./lib/cache/CacheCleanup.js", "./package.json": "./package.json" }, "engines": { diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index 4b624ff63f5..803cff1a4eb 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -236,3 +236,79 @@ test.serial("Batch operations: metadata batch rollback", async (t) => { t.is(result, null, "Metadata should not exist after rollback"); cm.close(); }); + +test.serial("getCacheInfo: returns null for non-existent cache", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + const result = await CacheManager.getCacheInfo(testDir); + t.is(result, null); +}); + +test.serial("getCacheInfo: returns null for empty cache", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + // Create empty cache + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.close(); + + const result = await CacheManager.getCacheInfo(testDir); + t.is(result, null); +}); + +test.serial("getCacheInfo: returns info for cache with records", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + // Create cache with data + const cm = new CacheManager(path.join(testDir, "buildCache")); + await cm.writeIndexCache("proj", "sig", "source", {data: true}); + cm.close(); + + const result = await CacheManager.getCacheInfo(testDir); + t.truthy(result); + t.true(result.path.includes("buildCache")); + t.true(result.path.includes("v0_7")); + t.is(result.type, "database"); + t.true(result.size > 0); +}); + +test.serial("cleanCache: returns null for non-existent cache", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + const result = await CacheManager.cleanCache(testDir); + t.is(result, null); +}); + +test.serial("cleanCache: returns null for empty cache", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + // Create empty cache + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.close(); + + const result = await CacheManager.cleanCache(testDir); + t.is(result, null); +}); + +test.serial("cleanCache: clears cache and returns result", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + // Create cache with data + const cm = new CacheManager(path.join(testDir, "buildCache")); + await cm.writeIndexCache("proj", "sig", "source", {data: true}); + cm.putContent("sha256-test", Buffer.from("content")); + cm.close(); + + const result = await CacheManager.cleanCache(testDir); + t.truthy(result); + t.true(result.path.includes("buildCache")); + t.true(result.path.includes("v0_7")); + t.is(result.type, "buildCache"); + t.true(result.size >= 0); + + // Verify cache is empty + const cm2 = new CacheManager(path.join(testDir, "buildCache")); + const check = await cm2.readIndexCache("proj", "sig", "source"); + t.is(check, null); + t.false(cm2.hasContent("sha256-test")); + cm2.close(); +}); diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js deleted file mode 100644 index f9b4d784d20..00000000000 --- a/packages/project/test/lib/cache/CacheCleanup.js +++ /dev/null @@ -1,267 +0,0 @@ -import test from "ava"; -import path from "node:path"; -import fs from "node:fs/promises"; -import {rimraf} from "rimraf"; -import {cleanCache, getCacheInfo} from "../../../lib/cache/CacheCleanup.js"; - -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); - -test.after.always(async () => { - await rimraf(TEST_DIR).catch(() => {}); -}); - -function createTestDir(t) { - const dir = path.join(TEST_DIR, `test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - t.context.ui5DataDir = dir; - return dir; -} - -async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { - const pkgDir = path.join(ui5DataDir, "framework", "packages", scope, name, version); - await fs.mkdir(pkgDir, {recursive: true}); - const filePath = path.join(pkgDir, "package.json"); - await fs.writeFile(filePath, JSON.stringify({name: `${scope}/${name}`, version})); - if (mtime) { - await fs.utimes(filePath, mtime, mtime); - } -} - -// ===== cleanCache tests ===== - -test("cleanCache: returns empty result for nonexistent directory", async (t) => { - const result = await cleanCache({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); - t.is(result.totalCount, 0); - t.is(result.totalSize, 0); - t.deepEqual(result.entries, []); -}); - -test("cleanCache: clean all removes framework packages", async (t) => { - const ui5DataDir = createTestDir(t); - await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); - await createPackage(ui5DataDir, "@openui5", "sap.m", "1.120.0"); - - const result = await cleanCache({ui5DataDir}); - - t.true(result.totalCount >= 1); - const frameworkEntries = result.entries.filter((e) => e.type === "framework"); - t.is(frameworkEntries.length, 1); - t.is(frameworkEntries[0].path, "framework"); -}); - -test("cleanCache: clean all clears buildCache database", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); - CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); - CREATE TABLE stage_metadata - (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); - CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); - CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); - `); - db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); - db.exec("INSERT INTO index_cache VALUES ('proj', 'sig', 'source', 'data')"); - db.close(); - - const result = await cleanCache({ui5DataDir}); - - const buildCacheEntry = result.entries.find((e) => e.type === "buildCache"); - t.truthy(buildCacheEntry); - - await fs.access(buildCacheDir); - await fs.access(dbPath); - - const dbAfter = new DatabaseSync(dbPath); - const contentCount = dbAfter.prepare("SELECT COUNT(*) as count FROM content").get().count; - const indexCount = dbAfter.prepare("SELECT COUNT(*) as count FROM index_cache").get().count; - t.is(contentCount, 0); - t.is(indexCount, 0); - dbAfter.close(); -}); - -test("cleanCache: skips empty framework directory", async (t) => { - const ui5DataDir = createTestDir(t); - const frameworkDir = path.join(ui5DataDir, "framework"); - await fs.mkdir(frameworkDir, {recursive: true}); - - const result = await cleanCache({ui5DataDir}); - - t.is(result.totalCount, 0); - const frameworkEntries = result.entries.filter((e) => e.type === "framework"); - t.is(frameworkEntries.length, 0); -}); - -test("cleanCache: cleans both framework and build cache", async (t) => { - const ui5DataDir = createTestDir(t); - - await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); - - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT); - CREATE TABLE index_cache (project_id TEXT); - CREATE TABLE stage_metadata (project_id TEXT); - CREATE TABLE task_metadata (project_id TEXT); - CREATE TABLE result_metadata (project_id TEXT); - `); - db.exec("INSERT INTO content VALUES ('test')"); - db.close(); - - const result = await cleanCache({ui5DataDir}); - - t.true(result.totalCount >= 1); // At least framework - t.truthy(result.entries.find((e) => e.type === "framework")); - t.true(result.totalSize > 0); - // Build cache may also be cleaned - if (result.totalCount === 2) { - t.truthy(result.entries.find((e) => e.type === "buildCache")); - } -}); - -test("cleanCache: handles corrupted database gracefully", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - await fs.writeFile(path.join(buildCacheDir, "cache.db"), "not a valid database"); - - const result = await cleanCache({ui5DataDir}); - - t.pass(); - const buildEntries = result.entries.filter((e) => e.type === "buildCache"); - t.is(buildEntries.length, 0); -}); - -// ===== getCacheInfo tests ===== - -test("getCacheInfo: returns empty array for nonexistent directory", async (t) => { - const items = await getCacheInfo({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); - t.deepEqual(items, []); -}); - -test("getCacheInfo: detects framework cache with size", async (t) => { - const ui5DataDir = createTestDir(t); - await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); - - const items = await getCacheInfo({ui5DataDir}); - - const frameworkItem = items.find((item) => item.path === "framework/"); - t.truthy(frameworkItem); - t.true(frameworkItem.size > 0); - t.is(frameworkItem.type, "directory"); -}); - -test("getCacheInfo: skips empty framework directory", async (t) => { - const ui5DataDir = createTestDir(t); - const frameworkDir = path.join(ui5DataDir, "framework"); - await fs.mkdir(frameworkDir, {recursive: true}); - - const items = await getCacheInfo({ui5DataDir}); - - const frameworkItem = items.find((item) => item.path === "framework/"); - t.falsy(frameworkItem); -}); - -test("getCacheInfo: detects build cache with records", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); - CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); - CREATE TABLE stage_metadata - (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); - CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); - CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); - `); - db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); - db.close(); - - const items = await getCacheInfo({ui5DataDir}); - - const buildItem = items.find((item) => item.type === "database"); - t.truthy(buildItem); - t.is(buildItem.path, "buildCache/v0_7 (database records)"); - t.true(buildItem.size > 0); -}); - -test("getCacheInfo: skips build cache with no records", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); - CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); - CREATE TABLE stage_metadata - (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); - CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); - CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); - `); - db.close(); - - const items = await getCacheInfo({ui5DataDir}); - - const buildItem = items.find((item) => item.type === "database"); - t.falsy(buildItem); -}); - -test("getCacheInfo: handles corrupted database gracefully", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - await fs.writeFile(path.join(buildCacheDir, "cache.db"), "not a valid database"); - - const items = await getCacheInfo({ui5DataDir}); - - t.pass(); - const buildItem = items.find((item) => item.type === "database"); - t.falsy(buildItem); -}); - -test("getCacheInfo: detects both framework and build cache", async (t) => { - const ui5DataDir = createTestDir(t); - - await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); - - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT); - CREATE TABLE index_cache (project_id TEXT); - CREATE TABLE stage_metadata (project_id TEXT); - CREATE TABLE task_metadata (project_id TEXT); - CREATE TABLE result_metadata (project_id TEXT); - `); - db.exec("INSERT INTO content VALUES ('test')"); - db.close(); - - const items = await getCacheInfo({ui5DataDir}); - - t.true(items.length >= 1); // At least framework - t.truthy(items.find((item) => item.path === "framework/")); - // Build cache may also be detected - if (items.length === 2) { - t.truthy(items.find((item) => item.type === "database")); - } -}); diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index ec16c6e22bc..35f7a032be3 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) diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js new file mode 100644 index 00000000000..c09c80708c0 --- /dev/null +++ b/packages/project/test/lib/ui5framework/cache.js @@ -0,0 +1,101 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import os from "node:os"; +import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; + +test.beforeEach(async (t) => { + const testDir = path.join(os.tmpdir(), `ui5-framework-cache-test-${Date.now()}-${Math.random()}`); + await fs.mkdir(testDir, {recursive: true}); + t.context.testDir = testDir; +}); + +test.afterEach.always(async (t) => { + if (t.context.testDir) { + await fs.rm(t.context.testDir, {recursive: true, force: true}); + } +}); + +test("getCacheInfo: empty directory returns null", async (t) => { + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: non-existent directory returns null", async (t) => { + const nonExistent = path.join(t.context.testDir, "does-not-exist"); + const result = await getCacheInfo(nonExistent); + t.is(result, null); +}); + +test("getCacheInfo: detects framework directory with files", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework/"); + t.is(result.type, "directory"); + t.true(result.size > 0); +}); + +test("getCacheInfo: returns null for empty framework directory", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: calculates size recursively", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const subDir = path.join(frameworkDir, "packages"); + await fs.mkdir(subDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "file1.txt"), "test1"); + await fs.writeFile(path.join(subDir, "file2.txt"), "test2"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.true(result.size >= 10); // At least 5 + 5 bytes +}); + +test("cleanCache: returns null for non-existent directory", async (t) => { + const result = await cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: returns null for empty directory", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + + const result = await cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: removes framework directory", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + const result = await cleanCache(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.type, "framework"); + t.true(result.size > 0); + + // Verify directory was removed + await t.throwsAsync(fs.access(frameworkDir)); +}); + +test("cleanCache: removes nested directories", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const subDir = path.join(frameworkDir, "packages"); + await fs.mkdir(subDir, {recursive: true}); + await fs.writeFile(path.join(subDir, "test.txt"), "content"); + + const result = await cleanCache(t.context.testDir); + t.truthy(result); + + // Verify directory and subdirectories were removed + await t.throwsAsync(fs.access(frameworkDir)); +}); From c1eb38c4caacf60f62ce0bd024c2b68d8a1dbb0a Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 1 Jun 2026 13:44:16 +0300 Subject: [PATCH 12/18] refactor: Add skip confirmation option --- packages/cli/lib/cli/commands/cache.js | 30 +++++++++++++------- packages/cli/test/lib/cli/commands/cache.js | 31 ++++++++++++++++++++- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 87108617141..21408a64596 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -20,15 +20,21 @@ cacheCommand.builder = function(cli) { .demandCommand(1, "Command required. Available command is 'clean'") .command("clean", "Remove all cached UI5 data", { handler: handleCache, - builder: noop, + builder: function(yargs) { + return yargs.option("interactive", { + describe: "Show confirmation prompt before cleaning. Use --no-interactive to skip (e.g. for CI)", + default: true, + type: "boolean", + }); + }, middlewares: [baseMiddleware], }) .example("$0 cache clean", - "Remove all cached UI5 data"); + "Remove all cached UI5 data") + .example("$0 cache clean --no-interactive", + "Remove all cached UI5 data without confirmation (CI mode)"); }; -function noop() {} - /** * Format a byte size as a human-readable string. * @@ -66,7 +72,9 @@ async function confirm(question) { }); } -async function handleCache() { +async function handleCache(argv) { + const interactive = argv?.interactive !== false; + // Resolve UI5 data directory let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { @@ -105,11 +113,13 @@ async function handleCache() { } process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`)); - // Ask for confirmation - const confirmed = await confirm("Do you want to continue? (y/N) "); - if (!confirmed) { - process.stderr.write("Cancelled\n"); - return; + // Ask for confirmation (skip in non-interactive mode) + if (interactive) { + const confirmed = await confirm("Do you want to continue? (y/N) "); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } } // Perform the actual cleanup (orchestrate both domains) diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 06ab7e9d61b..aa5f81e4216 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -71,7 +71,7 @@ test("Command builder", async (t) => { 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, 1, "example called once"); + t.is(cliStub.example.callCount, 2, "example called twice"); }); test.serial("ui5 cache clean: nothing to clean", async (t) => { @@ -253,3 +253,32 @@ test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async } } }); + +test.serial("ui5 cache clean --no-interactive: skips confirmation prompt", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves({ + path: "buildCache/v0_7 (database records)", size: 5 * 1024 * 1024, type: "database" + }); + + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 10 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 5 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["interactive"] = false; + await cache.handler(argv); + + // Confirmation should NOT be asked + t.is(mockRLInterface.question.callCount, 0, "Should not ask for confirmation in non-interactive mode"); + + // Cleanup should still proceed + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); + + // Check output + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); + t.true(allOutput.includes("Success"), "Shows success message"); +}); From 48aafe9b18cf9def7f974990d7027f41ca07fc96 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 1 Jun 2026 14:07:50 +0300 Subject: [PATCH 13/18] fix: Windows paths for tests --- packages/cli/test/lib/cli/commands/cache.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index aa5f81e4216..1b52bf2f45b 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -1,4 +1,5 @@ import test from "ava"; +import path from "node:path"; import sinon from "sinon"; import esmock from "esmock"; import Configuration from "@ui5/project/config/Configuration"; @@ -221,7 +222,7 @@ test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => await cache.handler(argv); t.is(frameworkCacheGetCacheInfo.callCount, 1, "frameworkCache.getCacheInfo called"); - t.true(frameworkCacheGetCacheInfo.firstCall.args[0].includes("custom/ui5/path"), + t.true(frameworkCacheGetCacheInfo.firstCall.args[0].includes(path.join("custom", "ui5", "path")), "Uses environment variable path"); } finally { if (originalEnv) { From 1c161bbb99740843cb71a99be9c3b6f66c05ba99 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 11:59:13 +0300 Subject: [PATCH 14/18] refactor: Use yesno package for CLI confirmation --- package-lock.json | 3 +- packages/cli/lib/cli/commands/cache.js | 42 +++----- packages/cli/package.json | 3 +- packages/cli/test/lib/cli/commands/cache.js | 109 +++++++++++--------- 4 files changed, 75 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index da05287a1e7..815b0c48087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18144,7 +18144,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.7.2", "update-notifier": "^7.3.1", - "yargs": "^18.0.0" + "yargs": "^18.0.0", + "yesno": "^0.4.0" }, "bin": { "ui5": "bin/ui5.cjs" diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 21408a64596..1217995a867 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -2,7 +2,6 @@ import chalk from "chalk"; import path from "node:path"; import os from "node:os"; import process from "node:process"; -import readline from "node:readline"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; @@ -21,9 +20,10 @@ cacheCommand.builder = function(cli) { .command("clean", "Remove all cached UI5 data", { handler: handleCache, builder: function(yargs) { - return yargs.option("interactive", { - describe: "Show confirmation prompt before cleaning. Use --no-interactive to skip (e.g. for CI)", - default: true, + return yargs.option("yes", { + alias: "y", + describe: "Skip confirmation prompt (e.g. for CI)", + default: false, type: "boolean", }); }, @@ -31,7 +31,7 @@ cacheCommand.builder = function(cli) { }) .example("$0 cache clean", "Remove all cached UI5 data") - .example("$0 cache clean --no-interactive", + .example("$0 cache clean --yes", "Remove all cached UI5 data without confirmation (CI mode)"); }; @@ -52,29 +52,7 @@ function formatSize(bytes) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } -/** - * Prompt user for confirmation. - * - * @param {string} question The question to ask - * @returns {Promise} True if user confirmed - */ -async function confirm(question) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr - }); - - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); - }); - }); -} - async function handleCache(argv) { - const interactive = argv?.interactive !== false; - // Resolve UI5 data directory let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { @@ -113,9 +91,13 @@ async function handleCache(argv) { } process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`)); - // Ask for confirmation (skip in non-interactive mode) - if (interactive) { - const confirmed = await confirm("Do you want to continue? (y/N) "); + // Ask for confirmation (skip with --yes) + if (!argv.yes) { + const {default: yesno} = await import("yesno"); + const confirmed = await yesno({ + question: "Do you want to continue? (y/N)", + defaultValue: false + }); if (!confirmed) { process.stderr.write("Cancelled\n"); return; diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e53acf2327..74ebe767605 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -64,7 +64,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.7.2", "update-notifier": "^7.3.1", - "yargs": "^18.0.0" + "yargs": "^18.0.0", + "yesno": "^0.4.0" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 1b52bf2f45b..9db1d349f33 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -29,13 +29,8 @@ test.beforeEach(async (t) => { t.context.buildCacheGetCacheInfo = sinon.stub(); t.context.buildCacheCleanCache = sinon.stub(); - // Mock readline to simulate user confirmation - const mockRLInterface = { - question: sinon.stub(), - close: sinon.stub() - }; - t.context.readlineCreateInterfaceStub = sinon.stub().returns(mockRLInterface); - t.context.mockRLInterface = mockRLInterface; + // Mock yesno to simulate user confirmation + t.context.yesnoStub = sinon.stub(); t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, @@ -49,8 +44,8 @@ test.beforeEach(async (t) => { static cleanCache = t.context.buildCacheCleanCache; } }, - "node:readline": { - createInterface: t.context.readlineCreateInterfaceStub, + "yesno": { + default: t.context.yesnoStub, }, }); }); @@ -93,7 +88,7 @@ test.serial("ui5 cache clean: nothing to clean", async (t) => { test.serial("ui5 cache clean: removes entries and reports", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Simulate existing cache items frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024, type: "directory"}); @@ -102,9 +97,7 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { }); // Mock user confirmation - mockRLInterface.question.callsFake((question, callback) => { - callback("y"); - }); + yesnoStub.resolves(true); frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 15 * 1024 * 1024}); buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 8 * 1024 * 1024}); @@ -113,8 +106,8 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { await cache.handler(argv); // Check that confirmation was asked - t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); - t.true(mockRLInterface.question.firstCall.args[0].includes("continue"), + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); + t.true(yesnoStub.firstCall.args[0].question.includes("continue"), "Confirmation question should ask to continue"); // Check that cleanCache was called @@ -130,22 +123,20 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { test.serial("ui5 cache clean: user cancels", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Simulate existing cache items frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024, type: "directory"}); buildCacheGetCacheInfo.resolves(null); // Mock user cancellation - mockRLInterface.question.callsFake((question, callback) => { - callback("n"); - }); + yesnoStub.resolves(false); argv["_"] = ["cache", "clean"]; await cache.handler(argv); // Check that confirmation was asked - t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); // Check that cleanup was NOT called t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called when user cancels"); @@ -166,36 +157,15 @@ test.serial("Command definition is correct", (t) => { t.is(typeof t.context.cache.handler, "function"); }); -test.serial("ui5 cache clean: accepts 'yes' as confirmation", async (t) => { - const {cache, argv, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheGetCacheInfo, mockRLInterface} = t.context; - - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 1024, type: "directory"}); - buildCacheGetCacheInfo.resolves(null); - - mockRLInterface.question.callsFake((question, callback) => { - callback("yes"); - }); - - frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 1024}); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called with 'yes' confirmation"); -}); - test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - // Test with small bytes (B), KB, and GB sizes - frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); // < 1024 = B - buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); // KB + // Test with B, KB sizes + frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); + buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); - mockRLInterface.question.callsFake((question, callback) => { - callback("y"); - }); + yesnoStub.resolves(true); frameworkCacheCleanCache.resolves({path: "small", type: "directory", size: 512}); buildCacheCleanCache.resolves({path: "medium", type: "database", size: 50 * 1024}); @@ -255,9 +225,9 @@ test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async } }); -test.serial("ui5 cache clean --no-interactive: skips confirmation prompt", async (t) => { +test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024, type: "directory"}); buildCacheGetCacheInfo.resolves({ @@ -268,11 +238,11 @@ test.serial("ui5 cache clean --no-interactive: skips confirmation prompt", async buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 5 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; - argv["interactive"] = false; + argv["yes"] = true; await cache.handler(argv); // Confirmation should NOT be asked - t.is(mockRLInterface.question.callCount, 0, "Should not ask for confirmation in non-interactive mode"); + t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --yes"); // Cleanup should still proceed t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); @@ -283,3 +253,42 @@ test.serial("ui5 cache clean --no-interactive: skips confirmation prompt", async t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); t.true(allOutput.includes("Success"), "Shows success message"); }); + +test.serial("ui5 cache clean: single entry with zero size and GB formatting", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + // Single cache item with size 0 — covers singular "entry", no "freed", and size=0 branches + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 0, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); + + yesnoStub.resolves(true); + + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 0}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1 entry"), "Summary uses singular 'entry'"); + t.false(allOutput.includes("freed"), "Should not show 'freed' for zero-size removal"); + + // Reset and test GB formatting + stderrWriteStub.resetHistory(); + frameworkCacheGetCacheInfo.resetBehavior(); + frameworkCacheCleanCache.resetBehavior(); + buildCacheGetCacheInfo.resetBehavior(); + buildCacheCleanCache.resetBehavior(); + frameworkCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); + frameworkCacheCleanCache.resolves({path: "large", type: "directory", size: 2.5 * 1024 * 1024 * 1024}); + + argv["yes"] = true; + await cache.handler(argv); + + const gbOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(gbOutput.includes("2.5 GB"), "Shows GB format"); +}); From 2631451caed4d957f775c037eee46ff838801342 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 12:13:34 +0300 Subject: [PATCH 15/18] refactor: Simplify cleanup meta structure --- packages/cli/test/lib/cli/commands/cache.js | 34 +++++++++---------- .../project/lib/build/cache/CacheManager.js | 6 ++-- packages/project/lib/ui5Framework/cache.js | 6 ++-- .../test/lib/build/cache/CacheManager.js | 2 -- .../project/test/lib/ui5framework/cache.js | 2 -- 5 files changed, 21 insertions(+), 29 deletions(-) diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 9db1d349f33..a99f36670d0 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -91,16 +91,16 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Simulate existing cache items - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024}); buildCacheGetCacheInfo.resolves({ - path: "buildCache/v0_7 (database records)", size: 8 * 1024 * 1024, type: "database" + path: "buildCache/v0_7 (database records)", size: 8 * 1024 * 1024 }); // Mock user confirmation yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 15 * 1024 * 1024}); - buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 8 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", size: 15 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -126,7 +126,7 @@ test.serial("ui5 cache clean: user cancels", async (t) => { buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Simulate existing cache items - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024}); buildCacheGetCacheInfo.resolves(null); // Mock user cancellation @@ -162,13 +162,13 @@ test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Test with B, KB sizes - frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); - buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); + frameworkCacheGetCacheInfo.resolves({path: "small", size: 512}); + buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024}); yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "small", type: "directory", size: 512}); - buildCacheCleanCache.resolves({path: "medium", type: "database", size: 50 * 1024}); + frameworkCacheCleanCache.resolves({path: "small", size: 512}); + buildCacheCleanCache.resolves({path: "medium", size: 50 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -229,13 +229,13 @@ test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024}); buildCacheGetCacheInfo.resolves({ - path: "buildCache/v0_7 (database records)", size: 5 * 1024 * 1024, type: "database" + path: "buildCache/v0_7 (database records)", size: 5 * 1024 * 1024 }); - frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 10 * 1024 * 1024}); - buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 5 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", size: 10 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; argv["yes"] = true; @@ -259,12 +259,12 @@ test.serial("ui5 cache clean: single entry with zero size and GB formatting", as buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Single cache item with size 0 — covers singular "entry", no "freed", and size=0 branches - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 0, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 0}); buildCacheGetCacheInfo.resolves(null); yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 0}); + frameworkCacheCleanCache.resolves({path: "framework", size: 0}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -282,9 +282,9 @@ test.serial("ui5 cache clean: single entry with zero size and GB formatting", as frameworkCacheCleanCache.resetBehavior(); buildCacheGetCacheInfo.resetBehavior(); buildCacheCleanCache.resetBehavior(); - frameworkCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); buildCacheGetCacheInfo.resolves(null); - frameworkCacheCleanCache.resolves({path: "large", type: "directory", size: 2.5 * 1024 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); argv["yes"] = true; await cache.handler(argv); diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 9ecf58b8d7d..524990e3c63 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -390,7 +390,7 @@ export default class CacheManager { * * @static * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null + * @returns {Promise<{path: string, size: number}|null>} Build cache info or null */ static async getCacheInfo(ui5DataDir) { const buildCacheDir = path.join(ui5DataDir, "buildCache"); @@ -404,7 +404,6 @@ export default class CacheManager { return { path: `buildCache/${CACHE_VERSION}`, size, - type: "database" }; } } finally { @@ -421,7 +420,7 @@ export default class CacheManager { * * @static * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + * @returns {Promise<{path: string, size: number}|null>} Removal result or null */ static async cleanCache(ui5DataDir) { const buildCacheDir = path.join(ui5DataDir, "buildCache"); @@ -434,7 +433,6 @@ export default class CacheManager { const freedSize = storage.clearAllRecords(); return { path: `buildCache/${CACHE_VERSION}`, - type: "buildCache", size: freedSize }; } diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 9d3b19b7448..e738996b96d 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -35,7 +35,7 @@ async function getDirectorySize(dirPath) { * Get framework cache info. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number, type: string}|null>} Framework cache info or null + * @returns {Promise<{path: string, size: number}|null>} Framework cache info or null */ export async function getCacheInfo(ui5DataDir) { const frameworkDir = path.join(ui5DataDir, "framework"); @@ -46,7 +46,6 @@ export async function getCacheInfo(ui5DataDir) { return { path: "framework/", size, - type: "directory" }; } } catch { @@ -59,7 +58,7 @@ export async function getCacheInfo(ui5DataDir) { * Clean framework cache directory. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + * @returns {Promise<{path: string, size: number}|null>} Removal result or null */ export async function cleanCache(ui5DataDir) { const frameworkDir = path.join(ui5DataDir, "framework"); @@ -69,7 +68,6 @@ export async function cleanCache(ui5DataDir) { await fs.rm(frameworkDir, {recursive: true, force: true}); return { path: "framework", - type: "framework", size }; } diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index 803cff1a4eb..bf2838f7550 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -267,7 +267,6 @@ test.serial("getCacheInfo: returns info for cache with records", async (t) => { t.truthy(result); t.true(result.path.includes("buildCache")); t.true(result.path.includes("v0_7")); - t.is(result.type, "database"); t.true(result.size > 0); }); @@ -302,7 +301,6 @@ test.serial("cleanCache: clears cache and returns result", async (t) => { t.truthy(result); t.true(result.path.includes("buildCache")); t.true(result.path.includes("v0_7")); - t.is(result.type, "buildCache"); t.true(result.size >= 0); // Verify cache is empty diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index c09c80708c0..a8eacf22455 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -35,7 +35,6 @@ test("getCacheInfo: detects framework directory with files", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); t.is(result.path, "framework/"); - t.is(result.type, "directory"); t.true(result.size > 0); }); @@ -80,7 +79,6 @@ test("cleanCache: removes framework directory", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); - t.is(result.type, "framework"); t.true(result.size > 0); // Verify directory was removed From 1ade6fde67edd3d0518a4789ae8589514a84f344 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 12:37:27 +0300 Subject: [PATCH 16/18] fix: Add guard to not accidently create a new DB --- packages/project/lib/build/cache/CacheManager.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 524990e3c63..46adba2856c 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,5 +1,6 @@ import path from "node:path"; import os from "node:os"; +import {access} from "node:fs/promises"; import Configuration from "../../config/Configuration.js"; import {getLogger} from "@ui5/logger"; import BuildCacheStorage from "./BuildCacheStorage.js"; @@ -396,6 +397,13 @@ export default class CacheManager { const buildCacheDir = path.join(ui5DataDir, "buildCache"); const dbDir = path.join(buildCacheDir, CACHE_VERSION); + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return null; + } + try { const storage = new BuildCacheStorage(dbDir); try { @@ -426,6 +434,13 @@ export default class CacheManager { const buildCacheDir = path.join(ui5DataDir, "buildCache"); const dbDir = path.join(buildCacheDir, CACHE_VERSION); + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return null; + } + try { const storage = new BuildCacheStorage(dbDir); try { From 8a53eb80381caf713ba31f94557ea975f9373885 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 15:56:49 +0300 Subject: [PATCH 17/18] refactor: Reuse meta from installers in cache cleanup --- packages/cli/lib/cli/commands/cache.js | 10 +++ packages/cli/test/lib/cli/commands/cache.js | 47 +++++++++++++- .../lib/ui5Framework/AbstractInstaller.js | 5 +- .../lib/ui5Framework/_frameworkPaths.js | 61 +++++++++++++++++++ packages/project/lib/ui5Framework/cache.js | 60 ++++++++++++------ .../lib/ui5Framework/maven/Installer.js | 9 +-- .../project/lib/ui5Framework/npm/Installer.js | 7 ++- .../project/test/lib/ui5framework/cache.js | 43 +++++++++++++ 8 files changed, 215 insertions(+), 27 deletions(-) create mode 100644 packages/project/lib/ui5Framework/_frameworkPaths.js diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 1217995a867..4933c9af9ca 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -65,6 +65,16 @@ async function handleCache(argv) { ui5DataDir = path.join(os.homedir(), ".ui5"); } + // Abort early if a framework operation is holding a lock — before prompting the user + if (await frameworkCache.isFrameworkLocked(ui5DataDir)) { + process.stderr.write( + `${chalk.red("Error:")} Framework cache is currently locked by an active operation. ` + + "Please wait for it to finish and try again.\n" + ); + process.exitCode = 1; + return; + } + // Check what items exist before cleaning (orchestrate both domains) const items = []; const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index a99f36670d0..4a524af22e1 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -26,6 +26,7 @@ test.beforeEach(async (t) => { t.context.frameworkCacheGetCacheInfo = sinon.stub(); t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.frameworkCacheIsFrameworkLocked = sinon.stub().resolves(false); t.context.buildCacheGetCacheInfo = sinon.stub(); t.context.buildCacheCleanCache = sinon.stub(); @@ -36,7 +37,8 @@ test.beforeEach(async (t) => { "@ui5/project/config/Configuration": t.context.Configuration, "@ui5/project/ui5Framework/cache": { getCacheInfo: t.context.frameworkCacheGetCacheInfo, - cleanCache: t.context.frameworkCacheCleanCache + cleanCache: t.context.frameworkCacheCleanCache, + isFrameworkLocked: t.context.frameworkCacheIsFrameworkLocked, }, "@ui5/project/build/cache/CacheManager": { default: class { @@ -53,6 +55,8 @@ test.beforeEach(async (t) => { test.afterEach.always((t) => { sinon.restore(); esmock.purge(t.context.cache); + // Reset exit code — some tests verify that the handler sets process.exitCode = 1 + process.exitCode = undefined; }); test("Command builder", async (t) => { @@ -292,3 +296,44 @@ test.serial("ui5 cache clean: single entry with zero size and GB formatting", as const gbOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(gbOutput.includes("2.5 GB"), "Shows GB format"); }); + +test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; + + // Simulate active lock + frameworkCacheIsFrameworkLocked.resolves(true); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Error:"), "Shows Error (not Warning)"); + t.true(allOutput.includes("currently locked by an active operation"), "Shows lock conflict message"); + t.false(allOutput.includes("Success"), "Does not show success message"); + + // Neither getCacheInfo nor cleanCache should be called after a lock abort + t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo should not be called when locked"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache should not be called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when locked"); + t.is(process.exitCode, 1, "Exit code should be 1"); +}); + +test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, + buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; + + // Simulate active lock — --yes must NOT bypass the lock check + frameworkCacheIsFrameworkLocked.resolves(true); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Error:"), "Shows Error even with --yes"); + t.false(allOutput.includes("Success"), "Does not show success message"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache should not be called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when locked"); + t.is(process.exitCode, 1, "Exit code should be 1"); +}); diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index e13dea7f6e0..6f155ad0799 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -2,6 +2,7 @@ import path from "node:path"; import {mkdirp} from "../utils/fs.js"; import {promisify} from "node:util"; import {getLogger} from "@ui5/logger"; +import {LOCK_STALE_MS, getFrameworkLockDir} from "./_frameworkPaths.js"; const log = getLogger("ui5Framework:Installer"); // File name must not start with one or multiple dots and should not contain characters other than: @@ -22,7 +23,7 @@ class AbstractInstaller { if (!ui5DataDir) { throw new Error(`Installer: Missing parameter "ui5DataDir"`); } - this._lockDir = path.join(ui5DataDir, "framework", "locks"); + this._lockDir = getFrameworkLockDir(ui5DataDir); } async _synchronize(lockName, callback) { @@ -36,7 +37,7 @@ class AbstractInstaller { log.verbose("Locking " + lockPath); await lock(lockPath, { wait: 10000, - stale: 60000, + stale: LOCK_STALE_MS, retries: 10 }); try { diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js new file mode 100644 index 00000000000..fd1dede136d --- /dev/null +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -0,0 +1,61 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import {promisify} from "node:util"; + +// Directory name for framework packages within ui5DataDir +export const FRAMEWORK_DIR_NAME = "framework"; + +// Lockfile staleness threshold — must match the value used by AbstractInstaller#_synchronize +export const LOCK_STALE_MS = 60000; + +/** + * Resolve the absolute path to the framework directory within a UI5 data directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the framework directory + */ +export function getFrameworkDir(ui5DataDir) { + return path.join(ui5DataDir, FRAMEWORK_DIR_NAME); +} + +/** + * Resolve the absolute path to the framework locks directory within a UI5 data directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the framework locks directory + */ +export function getFrameworkLockDir(ui5DataDir) { + return path.join(ui5DataDir, FRAMEWORK_DIR_NAME, "locks"); +} + +/** + * Check whether any active (non-stale) lockfiles exist in the given locks directory, + * indicating an ongoing download or installation. + * + * @param {string} lockDir Absolute path to a locks directory + * @returns {Promise} True if any non-stale lockfiles are held + */ +export async function hasActiveLocks(lockDir) { + let entries; + try { + entries = await fs.readdir(lockDir); + } catch { + return false; + } + + const lockFiles = entries.filter((name) => name.endsWith(".lock")); + if (lockFiles.length === 0) { + return false; + } + + const {default: lockfile} = await import("lockfile"); + const check = promisify(lockfile.check); + for (const lockFileName of lockFiles) { + const lockPath = path.join(lockDir, lockFileName); + const isLocked = await check(lockPath, {stale: LOCK_STALE_MS}); + if (isLocked) { + return true; + } + } + return false; +} diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index e738996b96d..7b6fcd4664e 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -1,13 +1,20 @@ -import path from "node:path"; import fs from "node:fs/promises"; +import path from "node:path"; +import { + FRAMEWORK_DIR_NAME, + getFrameworkDir, + getFrameworkLockDir, + hasActiveLocks, +} from "./_frameworkPaths.js"; /** * Get the size of a directory tree recursively. + * Returns 0 if the directory does not exist or any entry is unreadable. * * @param {string} dirPath Absolute path to directory * @returns {Promise} Total size in bytes */ -async function getDirectorySize(dirPath) { +export async function getDirectorySize(dirPath) { let total = 0; let entries; try { @@ -38,13 +45,13 @@ async function getDirectorySize(dirPath) { * @returns {Promise<{path: string, size: number}|null>} Framework cache info or null */ export async function getCacheInfo(ui5DataDir) { - const frameworkDir = path.join(ui5DataDir, "framework"); + const frameworkDir = getFrameworkDir(ui5DataDir); try { await fs.access(frameworkDir); const size = await getDirectorySize(frameworkDir); if (size > 0) { return { - path: "framework/", + path: FRAMEWORK_DIR_NAME + "/", size, }; } @@ -54,25 +61,44 @@ export async function getCacheInfo(ui5DataDir) { return null; } +/** + * Check whether an active (non-stale) framework lock is currently held, + * indicating an ongoing download or installation. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise} True if an active lock is held + */ +export async function isFrameworkLocked(ui5DataDir) { + return hasActiveLocks(getFrameworkLockDir(ui5DataDir)); +} + /** * Clean framework cache directory. * + * Checks for active lockfiles before removing the directory to prevent + * deleting files while a download is in progress. + * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{path: string, size: number}|null>} Removal result or null + * @throws {Error} If framework packages are currently being installed (active lockfiles detected) */ export async function cleanCache(ui5DataDir) { - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - const size = await getDirectorySize(frameworkDir); - if (size > 0) { - await fs.rm(frameworkDir, {recursive: true, force: true}); - return { - path: "framework", - size - }; - } - } catch { - // Directory doesn't exist or couldn't be removed + const frameworkDir = getFrameworkDir(ui5DataDir); + const size = await getDirectorySize(frameworkDir); + if (size === 0) { + return null; } - return null; + + if (await hasActiveLocks(getFrameworkLockDir(ui5DataDir))) { + throw new Error( + "Framework cache is currently locked by an active operation. " + + "Please wait for it to finish and try again." + ); + } + + await fs.rm(frameworkDir, {recursive: true, force: true}); + return { + path: FRAMEWORK_DIR_NAME, + size, + }; } diff --git a/packages/project/lib/ui5Framework/maven/Installer.js b/packages/project/lib/ui5Framework/maven/Installer.js index 2c8e45fb7f6..008ca0290e5 100644 --- a/packages/project/lib/ui5Framework/maven/Installer.js +++ b/packages/project/lib/ui5Framework/maven/Installer.js @@ -8,6 +8,7 @@ import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; import SnapshotCache from "./SnapshotCache.js"; import {rmrf} from "../../utils/fs.js"; +import {getFrameworkDir} from "../_frameworkPaths.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); @@ -33,10 +34,10 @@ class Installer extends AbstractInstaller { constructor({ui5DataDir, snapshotEndpointUrlCb, snapshotCache = SnapshotCache.Default}) { super(ui5DataDir); - this._artifactsDir = path.join(ui5DataDir, "framework", "artifacts"); - this._packagesDir = path.join(ui5DataDir, "framework", "packages"); - this._metadataDir = path.join(ui5DataDir, "framework", "metadata"); - this._stagingDir = path.join(ui5DataDir, "framework", "staging"); + this._artifactsDir = path.join(getFrameworkDir(ui5DataDir), "artifacts"); + this._packagesDir = path.join(getFrameworkDir(ui5DataDir), "packages"); + this._metadataDir = path.join(getFrameworkDir(ui5DataDir), "metadata"); + this._stagingDir = path.join(getFrameworkDir(ui5DataDir), "staging"); this._snapshotCache = snapshotCache; this._snapshotEndpointUrlCb = snapshotEndpointUrlCb; diff --git a/packages/project/lib/ui5Framework/npm/Installer.js b/packages/project/lib/ui5Framework/npm/Installer.js index 40d1dae9814..1e9fa2b9b13 100644 --- a/packages/project/lib/ui5Framework/npm/Installer.js +++ b/packages/project/lib/ui5Framework/npm/Installer.js @@ -5,6 +5,7 @@ import {promisify} from "node:util"; import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; import {rmrf} from "../../utils/fs.js"; +import {getFrameworkDir} from "../_frameworkPaths.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); const rename = promisify(fs.rename); @@ -27,15 +28,15 @@ class Installer extends AbstractInstaller { throw new Error(`Installer: Missing parameter "cwd"`); } this._packagesDir = packagesDir ? - path.resolve(packagesDir) : path.join(ui5DataDir, "framework", "packages"); + path.resolve(packagesDir) : path.join(getFrameworkDir(ui5DataDir), "packages"); log.verbose(`Installing to: ${this._packagesDir}`); this._cwd = cwd; this._caCacheDir = cacheDir ? - path.resolve(cacheDir) : path.join(ui5DataDir, "framework", "cacache"); + path.resolve(cacheDir) : path.join(getFrameworkDir(ui5DataDir), "cacache"); this._stagingDir = stagingDir ? - path.resolve(stagingDir) : path.join(ui5DataDir, "framework", "staging"); + path.resolve(stagingDir) : path.join(getFrameworkDir(ui5DataDir), "staging"); } getRegistry() { diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index a8eacf22455..20626b7e20e 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -2,8 +2,13 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import os from "node:os"; +import {promisify} from "node:util"; +import lockfileLib from "lockfile"; import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; +const lockfileLock = promisify(lockfileLib.lock); +const lockfileUnlock = promisify(lockfileLib.unlock); + test.beforeEach(async (t) => { const testDir = path.join(os.tmpdir(), `ui5-framework-cache-test-${Date.now()}-${Math.random()}`); await fs.mkdir(testDir, {recursive: true}); @@ -97,3 +102,41 @@ test("cleanCache: removes nested directories", async (t) => { // Verify directory and subdirectories were removed await t.throwsAsync(fs.access(frameworkDir)); }); + +test("cleanCache: throws when active lockfiles exist", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const lockDir = path.join(frameworkDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + const lockPath = path.join(lockDir, "test-package.lock"); + await lockfileLock(lockPath, {stale: 60000}); + try { + const err = await t.throwsAsync(cleanCache(t.context.testDir)); + t.true(err.message.includes("currently locked by an active operation")); + } finally { + await lockfileUnlock(lockPath); + } +}); + +test("cleanCache: removes directory when lockfiles are stale", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const lockDir = path.join(frameworkDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + // Create a real lock with a very short stale threshold, then wait for it to expire. + // lockfile.check uses ctime — fs.utimes only changes mtime, so backdating mtime won't work. + const lockPath = path.join(lockDir, "stale-package.lock"); + await lockfileLock(lockPath, {stale: 50}); // stale after 50ms + await lockfileUnlock(lockPath); // unlock so ctime stops being "now" — file still exists on disk + // Wait long enough for the 50ms threshold to pass + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await cleanCache(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework"); + t.true(result.size > 0); + + await t.throwsAsync(fs.access(frameworkDir)); +}); From fd604cdc1332f4f88bee2422123152dc293fbe1e Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 18:54:32 +0300 Subject: [PATCH 18/18] refactor: Cleanup details --- packages/cli/lib/cli/commands/cache.js | 89 +++++--- packages/cli/test/lib/cli/commands/cache.js | 195 +++++++++--------- .../lib/build/cache/BuildCacheStorage.js | 6 +- .../project/lib/build/cache/CacheManager.js | 2 +- packages/project/lib/ui5Framework/cache.js | 34 ++- .../test/lib/build/cache/CacheManager.js | 2 + .../project/test/lib/ui5framework/cache.js | 11 +- 7 files changed, 180 insertions(+), 159 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 4933c9af9ca..b8d1d03b345 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -35,6 +35,11 @@ cacheCommand.builder = function(cli) { "Remove all cached UI5 data without confirmation (CI mode)"); }; +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. * @@ -52,6 +57,26 @@ function formatSize(bytes) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } +/** + * Format a count with its singular/plural word, e.g. "340 files" or "1 file". + * + * @param {number} count + * @returns {string} + */ +function formatFileCount(count) { + return `${count} ${count === 1 ? "file" : "files"}`; +} + +/** + * Pad a label to the shared column width. + * + * @param {string} label + * @returns {string} + */ +function padLabel(label) { + return label.padEnd(LABEL_WIDTH); +} + async function handleCache(argv) { // Resolve UI5 data directory let ui5DataDir = process.env.UI5_DATA_DIR; @@ -76,30 +101,29 @@ async function handleCache(argv) { } // Check what items exist before cleaning (orchestrate both domains) - const items = []; const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); - if (frameworkInfo) { - items.push(frameworkInfo); - } const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); - if (buildInfo) { - items.push(buildInfo); - } - if (items.length === 0) { + if (!frameworkInfo && !buildInfo) { process.stderr.write("Nothing to clean\n"); return; } // Display items that will be removed - process.stderr.write(chalk.bold("\nThe following items from cache will be removed:\n")); - let totalSize = 0; - for (const item of items) { - totalSize += item.size; - const sizeStr = item.size > 0 ? ` (${formatSize(item.size)})` : ""; - process.stderr.write(` ${chalk.yellow("•")} ${item.path}${sizeStr}\n`); + process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); + if (frameworkInfo) { + const detail = formatFileCount(frameworkInfo.count); + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkInfo.path} (${detail})\n` + ); } - process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`)); + if (buildInfo) { + const detail = buildInfo.size > 0 ? formatSize(buildInfo.size) : ""; + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_BUILD)} ${buildInfo.path} (${detail})\n` + ); + } + process.stderr.write("\n"); // Ask for confirmation (skip with --yes) if (!argv.yes) { @@ -115,27 +139,34 @@ async function handleCache(argv) { } // Perform the actual cleanup (orchestrate both domains) - const removed = []; const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); + const buildResult = await CacheManager.cleanCache(ui5DataDir); + + process.stderr.write("\n"); if (frameworkResult) { - removed.push(frameworkResult); + const detail = formatFileCount(frameworkResult.count); + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + + ` (${frameworkResult.path} · ${detail})\n` + ); } - const buildResult = await CacheManager.cleanCache(ui5DataDir); if (buildResult) { - removed.push(buildResult); + const detail = buildResult.size > 0 ? formatSize(buildResult.size) : ""; + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_BUILD)}` + + ` (${buildResult.path}${detail ? ` · ${detail}` : ""})\n` + ); } - process.stderr.write("\n"); - for (const entry of removed) { - const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : ""; - process.stderr.write(`${chalk.green("✓")} Removed ${chalk.bold(entry.path)}${sizeStr}\n`); + // Success summary + const cleaned = []; + if (frameworkResult) { + cleaned.push(LABEL_FRAMEWORK); } - - const totalRemoved = removed.reduce((sum, entry) => sum + entry.size, 0); - process.stderr.write( - `\n${chalk.green("Success:")} Cleaned ${removed.length} ${removed.length === 1 ? "entry" : "entries"}` + - (totalRemoved > 0 ? `, freed ${formatSize(totalRemoved)}` : "") + "\n" - ); + if (buildResult) { + cleaned.push(LABEL_BUILD); + } + process.stderr.write(`\n${chalk.green("Success:")} Cleaned ${cleaned.join(" and ")}\n`); } export default cacheCommand; diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 4a524af22e1..3a27b08bcd0 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -60,7 +60,6 @@ test.afterEach.always((t) => { }); test("Command builder", async (t) => { - // Import cache module directly for builder test (before beforeEach stubs are created) const cacheModule = await import("../../../../lib/cli/commands/cache.js"); const cliStub = { demandCommand: sinon.stub().returnsThis(), @@ -74,11 +73,17 @@ test("Command builder", async (t) => { t.is(cliStub.example.callCount, 2, "example called twice"); }); +test.serial("Command definition is correct", (t) => { + t.is(t.context.cache.command, "cache"); + t.is(t.context.cache.describe, "Manage UI5 CLI cache"); + t.is(typeof t.context.cache.builder, "function"); + t.is(typeof t.context.cache.handler, "function"); +}); + test.serial("ui5 cache clean: nothing to clean", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; - // Simulate no cache items frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves(null); @@ -90,98 +95,133 @@ test.serial("ui5 cache clean: nothing to clean", async (t) => { t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called"); }); -test.serial("ui5 cache clean: removes entries and reports", async (t) => { +test.serial("ui5 cache clean: removes both entries and reports", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - // Simulate existing cache items - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024}); - buildCacheGetCacheInfo.resolves({ - path: "buildCache/v0_7 (database records)", size: 8 * 1024 * 1024 - }); + frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 340}); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); - // Mock user confirmation yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "framework", size: 15 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", count: 340}); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - // Check that confirmation was asked t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); - t.true(yesnoStub.firstCall.args[0].question.includes("continue"), - "Confirmation question should ask to continue"); - - // Check that cleanCache was called t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called once"); t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called once"); - // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); - t.true(allOutput.includes("2 entries"), "Summary mentions entry count"); - t.true(allOutput.includes("Success"), "Shows success message"); + // Pre-clean listing + t.true(allOutput.includes("UI5 Framework packages"), "Shows framework label"); + t.true(allOutput.includes("Build cache (DB)"), "Shows build cache label"); + t.true(allOutput.includes("framework/"), "Shows framework path"); + t.true(allOutput.includes("buildCache/v0_7"), "Shows build cache path"); + t.true(allOutput.includes("340 files"), "Shows framework file count"); + t.true(allOutput.includes("8.0 MB"), "Shows build cache size"); + t.false(allOutput.includes("Total:"), "Does not show total line"); + // Post-clean output + t.true(allOutput.includes("Removed UI5 Framework packages"), "Shows framework removed line"); + t.true(allOutput.includes("Removed Build cache (DB)"), "Shows build cache removed line"); + // Success line + 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; - // Simulate existing cache items - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 10}); buildCacheGetCacheInfo.resolves(null); - // Mock user cancellation yesnoStub.resolves(false); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - // Check that confirmation was asked t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); - - // Check that cleanup was NOT called t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called when user cancels"); t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when user cancels"); - // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); t.false(allOutput.includes("Success"), "Should not show success message"); }); -test.serial("Command definition is correct", (t) => { - // Import without esmock for structure check - t.is(t.context.cache.command, "cache"); - t.is(t.context.cache.describe, "Manage UI5 CLI cache"); - t.is(typeof t.context.cache.builder, "function"); - t.is(typeof t.context.cache.handler, "function"); +test.serial("ui5 cache clean: framework only", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 1}); + buildCacheGetCacheInfo.resolves(null); + + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves({path: "framework", count: 1}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1 file"), "Uses singular 'file'"); + t.false(allOutput.includes("Build cache (DB)"), "Does not mention build cache"); + t.true(allOutput.includes("Cleaned UI5 Framework packages"), "Success mentions framework only"); + t.false(allOutput.includes("and Build"), "Success does not mention build cache"); }); -test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { +test.serial("ui5 cache clean: build only", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - // Test with B, KB sizes - frameworkCacheGetCacheInfo.resolves({path: "small", size: 512}); - buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024}); + 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", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheCleanCache.resolves({path: "small", size: 512}); - buildCacheCleanCache.resolves({path: "medium", size: 50 * 1024}); + 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("512 B"), "Shows bytes format"); 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: uses UI5_DATA_DIR from environment", async (t) => { const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; const originalEnv = process.env.UI5_DATA_DIR; @@ -233,89 +273,41 @@ test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024}); - buildCacheGetCacheInfo.resolves({ - path: "buildCache/v0_7 (database records)", size: 5 * 1024 * 1024 - }); + frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 100}); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); - frameworkCacheCleanCache.resolves({path: "framework", size: 10 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", count: 100}); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; argv["yes"] = true; await cache.handler(argv); - // Confirmation should NOT be asked t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --yes"); - - // Cleanup should still proceed t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); - // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); t.true(allOutput.includes("Success"), "Shows success message"); }); -test.serial("ui5 cache clean: single entry with zero size and GB formatting", async (t) => { - const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - - // Single cache item with size 0 — covers singular "entry", no "freed", and size=0 branches - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 0}); - buildCacheGetCacheInfo.resolves(null); - - yesnoStub.resolves(true); - - frameworkCacheCleanCache.resolves({path: "framework", size: 0}); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); - t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); - - const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("1 entry"), "Summary uses singular 'entry'"); - t.false(allOutput.includes("freed"), "Should not show 'freed' for zero-size removal"); - - // Reset and test GB formatting - stderrWriteStub.resetHistory(); - frameworkCacheGetCacheInfo.resetBehavior(); - frameworkCacheCleanCache.resetBehavior(); - buildCacheGetCacheInfo.resetBehavior(); - buildCacheCleanCache.resetBehavior(); - frameworkCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); - buildCacheGetCacheInfo.resolves(null); - frameworkCacheCleanCache.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); - - argv["yes"] = true; - await cache.handler(argv); - - const gbOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(gbOutput.includes("2.5 GB"), "Shows GB format"); -}); - test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; - // Simulate active lock frameworkCacheIsFrameworkLocked.resolves(true); argv["_"] = ["cache", "clean"]; await cache.handler(argv); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("Error:"), "Shows Error (not Warning)"); - t.true(allOutput.includes("currently locked by an active operation"), "Shows lock conflict message"); - t.false(allOutput.includes("Success"), "Does not show success message"); - - // Neither getCacheInfo nor cleanCache should be called after a lock abort - t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo should not be called when locked"); - t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache should not be called when locked"); - t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when locked"); + t.true(allOutput.includes("Error:"), "Shows Error"); + t.true(allOutput.includes("currently locked by an active operation"), "Shows lock message"); + t.false(allOutput.includes("Success"), "Does not show success"); + + t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo not called when locked"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when locked"); t.is(process.exitCode, 1, "Exit code should be 1"); }); @@ -323,7 +315,6 @@ test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; - // Simulate active lock — --yes must NOT bypass the lock check frameworkCacheIsFrameworkLocked.resolves(true); argv["_"] = ["cache", "clean"]; @@ -332,8 +323,8 @@ test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes("Error:"), "Shows Error even with --yes"); - t.false(allOutput.includes("Success"), "Does not show success message"); - t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache should not be called when locked"); - t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when locked"); + t.false(allOutput.includes("Success"), "Does not show success"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when locked"); t.is(process.exitCode, 1, "Exit code should be 1"); }); diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index 0689e32bdb7..cd2e8246cbd 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -584,13 +584,15 @@ export default class BuildCacheStorage { hasRecords() { const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; for (const table of tables) { - const count = this.#db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()?.count ?? 0; - if (count > 0) { + 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 */ diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 46adba2856c..99455f84886 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -448,7 +448,7 @@ export default class CacheManager { const freedSize = storage.clearAllRecords(); return { path: `buildCache/${CACHE_VERSION}`, - size: freedSize + size: freedSize, }; } } finally { diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 7b6fcd4664e..8a0485eebaa 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -8,13 +8,13 @@ import { } from "./_frameworkPaths.js"; /** - * Get the size of a directory tree recursively. + * Count all files in a directory tree recursively. * Returns 0 if the directory does not exist or any entry is unreadable. * * @param {string} dirPath Absolute path to directory - * @returns {Promise} Total size in bytes + * @returns {Promise} Total file count */ -export async function getDirectorySize(dirPath) { +async function countFiles(dirPath) { let total = 0; let entries; try { @@ -23,16 +23,10 @@ export async function getDirectorySize(dirPath) { return 0; } for (const entry of entries) { - const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { - total += await getDirectorySize(entryPath); + total += await countFiles(path.join(dirPath, entry.name)); } else { - try { - const stat = await fs.stat(entryPath); - total += stat.size; - } catch { - // Skip inaccessible files - } + total++; } } return total; @@ -42,17 +36,17 @@ export async function getDirectorySize(dirPath) { * Get framework cache info. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number}|null>} Framework cache info or null + * @returns {Promise<{path: string, count: number}|null>} Framework cache info or null */ export async function getCacheInfo(ui5DataDir) { const frameworkDir = getFrameworkDir(ui5DataDir); try { await fs.access(frameworkDir); - const size = await getDirectorySize(frameworkDir); - if (size > 0) { + const count = await countFiles(frameworkDir); + if (count > 0) { return { path: FRAMEWORK_DIR_NAME + "/", - size, + count, }; } } catch { @@ -79,13 +73,13 @@ export async function isFrameworkLocked(ui5DataDir) { * deleting files while a download is in progress. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number}|null>} Removal result or null - * @throws {Error} If framework packages are currently being installed (active lockfiles detected) + * @returns {Promise<{path: string, count: number}|null>} Removal result or null + * @throws {Error} If a framework operation is currently active (active lockfiles detected) */ export async function cleanCache(ui5DataDir) { const frameworkDir = getFrameworkDir(ui5DataDir); - const size = await getDirectorySize(frameworkDir); - if (size === 0) { + const count = await countFiles(frameworkDir); + if (count === 0) { return null; } @@ -99,6 +93,6 @@ export async function cleanCache(ui5DataDir) { await fs.rm(frameworkDir, {recursive: true, force: true}); return { path: FRAMEWORK_DIR_NAME, - size, + count, }; } diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index bf2838f7550..6df2cf03595 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -267,6 +267,7 @@ test.serial("getCacheInfo: returns info for cache with records", async (t) => { t.truthy(result); t.true(result.path.includes("buildCache")); t.true(result.path.includes("v0_7")); + t.true(result.size > 0); }); @@ -301,6 +302,7 @@ test.serial("cleanCache: clears cache and returns result", async (t) => { t.truthy(result); t.true(result.path.includes("buildCache")); t.true(result.path.includes("v0_7")); + t.true(result.size >= 0); // Verify cache is empty diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 20626b7e20e..41105323333 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -40,7 +40,7 @@ test("getCacheInfo: detects framework directory with files", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); t.is(result.path, "framework/"); - t.true(result.size > 0); + t.is(result.count, 1); }); test("getCacheInfo: returns null for empty framework directory", async (t) => { @@ -51,7 +51,7 @@ test("getCacheInfo: returns null for empty framework directory", async (t) => { t.is(result, null); }); -test("getCacheInfo: calculates size recursively", async (t) => { +test("getCacheInfo: counts files recursively", async (t) => { const frameworkDir = path.join(t.context.testDir, "framework"); const subDir = path.join(frameworkDir, "packages"); await fs.mkdir(subDir, {recursive: true}); @@ -60,7 +60,7 @@ test("getCacheInfo: calculates size recursively", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); - t.true(result.size >= 10); // At least 5 + 5 bytes + t.is(result.count, 2); }); test("cleanCache: returns null for non-existent directory", async (t) => { @@ -84,7 +84,7 @@ test("cleanCache: removes framework directory", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); - t.true(result.size > 0); + t.is(result.count, 1); // Verify directory was removed await t.throwsAsync(fs.access(frameworkDir)); @@ -98,6 +98,7 @@ test("cleanCache: removes nested directories", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); + t.is(result.count, 1); // Verify directory and subdirectories were removed await t.throwsAsync(fs.access(frameworkDir)); @@ -136,7 +137,7 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); - t.true(result.size > 0); + t.is(result.count, 1); // only test.txt remains — stale lock file is deleted by lockfileUnlock await t.throwsAsync(fs.access(frameworkDir)); });