From 1a7a57ca8579c83bc435f62d93f1f1662854296b Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 22 May 2026 15:16:52 +0300 Subject: [PATCH 01/11] 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 585aa36014a4a23abbe04614c2932dc58bd0395c Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 11:48:58 +0300 Subject: [PATCH 02/11] 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 aa280da8cb3a91bf6a857430edbbcc9aeeba3229 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 13:10:28 +0300 Subject: [PATCH 03/11] 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 2691fb4fb33d02ca4a647e9cf849342e31373f99 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:00:12 +0300 Subject: [PATCH 04/11] 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 60e16d42c56735d262a58b30a4431121cb41197c Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:48:44 +0300 Subject: [PATCH 05/11] 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 366b7ce3a724d00c151494e1e511b1893088ccf8 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:56:25 +0300 Subject: [PATCH 06/11] 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 6b127465b87645811ca8029bcde2277409bfe099 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 15:18:48 +0300 Subject: [PATCH 07/11] 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 1bd076901c9916ff931a43d44f92a2a21566a1dd Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 15:50:10 +0300 Subject: [PATCH 08/11] 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 7473bd444a204c4e85b35b28c34201af97e0aa0d Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 16:01:30 +0300 Subject: [PATCH 09/11] 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 1865be04c3f185025bcf82241142ccc5fbdfcaf6 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 16:13:13 +0300 Subject: [PATCH 10/11] 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 569ff71844ea0be3f87c6fd1772a775c704b03d1 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 18:36:37 +0300 Subject: [PATCH 11/11] 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)); +});