diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js new file mode 100644 index 00000000000..87108617141 --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -0,0 +1,139 @@ +import chalk from "chalk"; +import path from "node:path"; +import os from "node:os"; +import process from "node:process"; +import readline from "node:readline"; +import baseMiddleware from "../middlewares/base.js"; +import Configuration from "@ui5/project/config/Configuration"; +import * as frameworkCache from "@ui5/project/ui5Framework/cache"; +import CacheManager from "@ui5/project/build/cache/CacheManager"; + +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`; +} + +/** + * 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; + 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"); + } + + // 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"); + 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 (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 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 ${removed.length} ${removed.length === 1 ? "entry" : "entries"}` + + (totalRemoved > 0 ? `, freed ${formatSize(totalRemoved)}` : "") + "\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..06ab7e9d61b --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,255 @@ +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.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 = { + 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/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, + }, + }); +}); + +test.afterEach.always((t) => { + sinon.restore(); + 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, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; + + // Simulate no cache items + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n"); + 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, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + + // Simulate existing cache items + 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"); + }); + + 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); + + // 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(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called once"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called once"); + + // Check output + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); + t.true(allOutput.includes("2 entries"), "Summary mentions entry count"); + t.true(allOutput.includes("Success"), "Shows success message"); +}); + +test.serial("ui5 cache clean: user cancels", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + + // Simulate existing cache items + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); + + // Mock user cancellation + mockRLInterface.question.callsFake((question, callback) => { + callback("n"); + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + // Check that confirmation was asked + t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); + + // Check that cleanup was NOT called + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called when user cancels"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when user cancels"); + + // Check output + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); + t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); + t.false(allOutput.includes("Success"), "Should not show success message"); +}); + +test.serial("Command definition is correct", (t) => { + // Import without esmock for structure check + t.is(t.context.cache.command, "cache"); + t.is(t.context.cache.describe, "Manage UI5 CLI cache"); + t.is(typeof t.context.cache.builder, "function"); + t.is(typeof t.context.cache.handler, "function"); +}); + +test.serial("ui5 cache clean: accepts 'yes' as confirmation", async (t) => { + const {cache, argv, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, mockRLInterface} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); + + mockRLInterface.question.callsFake((question, callback) => { + callback("yes"); + }); + + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called with 'yes' confirmation"); +}); + +test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + + // Test with small bytes (B), KB, and GB sizes + frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); // < 1024 = B + buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); // KB + + mockRLInterface.question.callsFake((question, callback) => { + callback("y"); + }); + + frameworkCacheCleanCache.resolves({path: "small", type: "directory", size: 512}); + buildCacheCleanCache.resolves({path: "medium", type: "database", size: 50 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("512 B"), "Shows bytes format"); + t.true(allOutput.includes("50.0 KB"), "Shows KB format"); +}); + +test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; + const originalEnv = process.env.UI5_DATA_DIR; + + try { + process.env.UI5_DATA_DIR = "/custom/ui5/path"; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + 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) { + 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, 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"})); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + 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 e2d9bff9a6a..0689e32bdb7 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -550,6 +550,47 @@ 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; + } + + /** + * 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 */ @@ -563,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 c1e057427b3..9ecf58b8d7d 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -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/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 d6fb584b4d6..2600710b8d2 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -20,12 +20,14 @@ "exports": { "./config/Configuration": "./lib/config/Configuration.js", "./build/cache/Cache": "./lib/build/cache/Cache.js", + "./build/cache/CacheManager": "./lib/build/cache/CacheManager.js", "./specifications/Specification": "./lib/specifications/Specification.js", "./specifications/SpecificationVersion": "./lib/specifications/SpecificationVersion.js", "./ui5Framework/Sapui5MavenSnapshotResolver": "./lib/ui5Framework/Sapui5MavenSnapshotResolver.js", "./ui5Framework/Openui5Resolver": "./lib/ui5Framework/Openui5Resolver.js", "./ui5Framework/Sapui5Resolver": "./lib/ui5Framework/Sapui5Resolver.js", "./ui5Framework/maven/SnapshotCache": "./lib/ui5Framework/maven/SnapshotCache.js", + "./ui5Framework/cache": "./lib/ui5Framework/cache.js", "./validation/validator": "./lib/validation/validator.js", "./validation/ValidationError": "./lib/validation/ValidationError.js", "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", 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/package-exports.js b/packages/project/test/lib/package-exports.js index 684e8634a84..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, 14); + 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)); +});