diff --git a/package-lock.json b/package-lock.json index da05287a1e7..815b0c48087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18144,7 +18144,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.7.2", "update-notifier": "^7.3.1", - "yargs": "^18.0.0" + "yargs": "^18.0.0", + "yesno": "^0.4.0" }, "bin": { "ui5": "bin/ui5.cjs" diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js new file mode 100644 index 00000000000..d6c246025be --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -0,0 +1,181 @@ +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 {getUi5DataDir} from "../../framework/utils.js"; +import * as frameworkCache from "@ui5/project/ui5Framework/cache"; +import CacheManager from "@ui5/project/build/cache/CacheManager"; + +const cacheCommand = { + command: "cache", + describe: "Manage the UI5 CLI cache (downloaded framework packages and incremental build data)", + middlewares: [baseMiddleware], + handler: handleCache +}; + +cacheCommand.builder = function(cli) { + return cli + .demandCommand(1, "Command required. Available command is 'clean'") + .command("clean", "Remove all cached UI5 data", { + handler: handleCache, + builder: function(yargs) { + return yargs + .option("yes", { + alias: "y", + describe: "Skip the confirmation prompt, e.g. for use in CI pipelines", + default: false, + type: "boolean", + }) + .example("$0 cache clean", + "Remove all cached UI5 data after confirming the prompt") + .example("$0 cache clean --yes", + "Remove all cached UI5 data without confirmation (e.g. in CI)") + .example("UI5_DATA_DIR=/custom/path $0 cache clean", + "Remove cached data from a non-default UI5 data directory"); + }, + middlewares: [baseMiddleware], + }); +}; + +const LABEL_FRAMEWORK = "UI5 Framework packages"; +const LABEL_BUILD = "Build cache (DB)"; +// Pad labels to equal width for two-column alignment +const LABEL_WIDTH = Math.max(LABEL_FRAMEWORK.length, LABEL_BUILD.length); + +/** + * Format a byte size as a human-readable string. + * + * @param {number} bytes Size in bytes + * @returns {string} Formatted size string + */ +function formatSize(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } else if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * Format a count with its singular/plural word, e.g. "340 files" or "1 file". + * + * @param {number} count + * @returns {string} + */ +function formatFileCount(count) { + return `${count} ${count === 1 ? "file" : "files"}`; +} + +/** + * Pad a label to the shared column width. + * + * @param {string} label + * @returns {string} + */ +function padLabel(label) { + return label.padEnd(LABEL_WIDTH); +} + +async function handleCache(argv) { + // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: + // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 + // Relative paths are resolved against process.cwd() (project root when invoked from the project). + const ui5DataDir = + (await getUi5DataDir({cwd: process.cwd()})) ?? path.join(os.homedir(), ".ui5"); + + // Abort early if a framework operation is holding a lock — before prompting the user + if (await frameworkCache.isFrameworkLocked(ui5DataDir)) { + process.stderr.write( + `${chalk.red("Error:")} Framework cache is currently locked by an active operation. ` + + "Please wait for it to finish and try again.\n" + ); + process.exitCode = 1; + return; + } + + // Inform the user immediately — getCacheInfo (especially countFiles) may take a moment + process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + + // Check what items exist before cleaning (orchestrate both domains) + const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); + const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); + + if (!frameworkInfo && !buildInfo) { + process.stderr.write("Nothing to clean\n"); + return; + } + + // Compute absolute paths once — producers return relative sub-path segments + const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; + const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; + + // Capture build size now — reused for the ✓ line to avoid a before/after mismatch + // (getDatabaseSize ≠ VACUUM-freed bytes returned by clearAllRecords) + const buildPreSize = buildInfo?.size ?? 0; + + // Display items that will be removed + process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); + if (frameworkInfo) { + const detail = formatFileCount(frameworkInfo.count); + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkAbsPath} (${detail})\n` + ); + } + if (buildInfo) { + const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_BUILD)} ${buildAbsPath} (${detail})\n` + ); + } + process.stderr.write("\n"); + + // Ask for confirmation (skip with --yes) + if (!argv.yes) { + const {default: yesno} = await import("yesno"); + const confirmed = await yesno({ + question: "Do you want to continue? (y/N)", + defaultValue: false + }); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } + } + + // Perform the actual cleanup (orchestrate both domains) + const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); + const buildResult = await CacheManager.cleanCache(ui5DataDir); + + process.stderr.write("\n"); + if (frameworkResult) { + const detail = formatFileCount(frameworkResult.count); + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + + ` (${frameworkAbsPath} · ${detail})\n` + ); + } + if (buildResult) { + // Use pre-clean size so the number matches what was shown before confirmation + const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_BUILD)}` + + ` (${buildAbsPath}${detail ? ` · ${detail}` : ""})\n` + ); + } + + // Success summary + const cleaned = []; + if (frameworkResult) { + cleaned.push(LABEL_FRAMEWORK); + } + if (buildResult) { + cleaned.push(LABEL_BUILD); + } + process.stderr.write(`\n${chalk.green("Success:")} Cleaned ${cleaned.join(" and ")}\n`); +} + +export default cacheCommand; diff --git a/packages/cli/lib/framework/utils.js b/packages/cli/lib/framework/utils.js index 799c8a35253..3bf2d5cd82d 100644 --- a/packages/cli/lib/framework/utils.js +++ b/packages/cli/lib/framework/utils.js @@ -49,7 +49,7 @@ export async function frameworkResolverResolveVersion({frameworkName, frameworkV }); } -async function getUi5DataDir({cwd}) { +export async function getUi5DataDir({cwd}) { // ENV var should take precedence over the dataDir from the configuration. let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e53acf2327..74ebe767605 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -64,7 +64,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.7.2", "update-notifier": "^7.3.1", - "yargs": "^18.0.0" + "yargs": "^18.0.0", + "yesno": "^0.4.0" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js new file mode 100644 index 00000000000..156b30e3958 --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,372 @@ +import test from "ava"; +import path from "node:path"; +import os from "node:os"; +import sinon from "sinon"; +import esmock from "esmock"; + +function getDefaultArgv() { + return { + "_": ["cache", "clean"], + "loglevel": "info", + "log-level": "info", + "logLevel": "info", + "perf": false, + "silent": false, + "$0": "ui5" + }; +} + +// Stable absolute path used as the resolved ui5DataDir in most tests +const TEST_UI5_DATA_DIR = path.resolve("/test/ui5/home"); + +test.beforeEach(async (t) => { + t.context.argv = getDefaultArgv(); + t.context.stderrWriteStub = sinon.stub(process.stderr, "write"); + + // Prevent real env var from leaking into tests + delete process.env.UI5_DATA_DIR; + + t.context.getUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); + + t.context.frameworkCacheGetCacheInfo = sinon.stub(); + t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.frameworkCacheIsFrameworkLocked = sinon.stub().resolves(false); + t.context.buildCacheGetCacheInfo = sinon.stub(); + t.context.buildCacheCleanCache = sinon.stub(); + + t.context.yesnoStub = sinon.stub(); + + t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { + "../../../../lib/framework/utils.js": { + getUi5DataDir: t.context.getUi5DataDirStub, + }, + "@ui5/project/ui5Framework/cache": { + getCacheInfo: t.context.frameworkCacheGetCacheInfo, + cleanCache: t.context.frameworkCacheCleanCache, + isFrameworkLocked: t.context.frameworkCacheIsFrameworkLocked, + }, + "@ui5/project/build/cache/CacheManager": { + default: class { + static getCacheInfo = t.context.buildCacheGetCacheInfo; + static cleanCache = t.context.buildCacheCleanCache; + } + }, + "yesno": { + default: t.context.yesnoStub, + }, + }); +}); + +test.afterEach.always((t) => { + sinon.restore(); + esmock.purge(t.context.cache); + process.exitCode = undefined; + delete process.env.UI5_DATA_DIR; +}); + +// ─── Command structure ────────────────────────────────────────────────────── + +test("Command builder", async (t) => { + const cacheModule = await import("../../../../lib/cli/commands/cache.js"); + const cliStub = { + demandCommand: sinon.stub().returnsThis(), + command: sinon.stub().returnsThis(), + example: sinon.stub().returnsThis(), + }; + const result = cacheModule.default.builder(cliStub); + t.is(result, cliStub, "Builder returns cli instance"); + t.is(cliStub.demandCommand.callCount, 1, "demandCommand called once"); + t.is(cliStub.command.callCount, 1, "command called once"); + t.is(cliStub.example.callCount, 0, "example not called on parent command"); +}); + +test.serial("Command definition is correct", (t) => { + t.is(t.context.cache.command, "cache"); + t.is(t.context.cache.describe, + "Manage the UI5 CLI cache (downloaded framework packages and incremental build data)"); + t.is(typeof t.context.cache.builder, "function"); + t.is(typeof t.context.cache.handler, "function"); +}); + +// ─── ui5DataDir resolution ────────────────────────────────────────────────── + +test.serial("ui5 cache clean: passes process.cwd() to getUi5DataDir", async (t) => { + const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(getUi5DataDirStub.callCount, 1, "getUi5DataDir called once"); + t.deepEqual(getUi5DataDirStub.firstCall.args[0], {cwd: process.cwd()}, + "Passes {cwd: process.cwd()} to getUi5DataDir"); +}); + +test.serial("ui5 cache clean: falls back to ~/.ui5 when getUi5DataDir returns undefined", async (t) => { + const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + stderrWriteStub} = t.context; + + // Simulate no env var, no config — getUi5DataDir returns undefined + getUi5DataDirStub.resolves(undefined); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const expectedDefault = path.join(os.homedir(), ".ui5"); + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(expectedDefault), + "Falls back to ~/.ui5 and shows it in checking line"); + + // getCacheInfo called with the default path + t.is(frameworkCacheGetCacheInfo.callCount, 1, "getCacheInfo called"); + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], expectedDefault, + "getCacheInfo receives ~/.ui5 as ui5DataDir"); +}); + +test.serial("ui5 cache clean: uses resolved path from getUi5DataDir", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, stderrWriteStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + // The stub returns TEST_UI5_DATA_DIR — verify it was passed to getCacheInfo + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, + "getCacheInfo receives the path returned by getUi5DataDir"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), + "Resolved ui5DataDir shown in checking line"); +}); + +test.serial("ui5 cache clean: relative path from config is resolved via getUi5DataDir", async (t) => { + // getUi5DataDir already resolves relative paths against cwd — verify the cache + // command uses the already-resolved absolute path rather than doing its own resolution. + const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; + + const resolvedPath = path.resolve(process.cwd(), "./custom-cache"); + getUi5DataDirStub.resolves(resolvedPath); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], resolvedPath, + "getCacheInfo receives the pre-resolved absolute path from getUi5DataDir"); +}); + +// ─── Basic flow ───────────────────────────────────────────────────────────── + +test.serial("ui5 cache clean: nothing to clean", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Checking cache at"), "Prints checking line"); + t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called"); +}); + +test.serial("ui5 cache clean: removes both entries and reports", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework", count: 340}); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); + + yesnoStub.resolves(true); + + frameworkCacheCleanCache.resolves({path: "framework", count: 340}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 7 * 1024 * 1024}); // VACUUM freed less + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache called once"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache called once"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + + // Checking line with absolute path + t.true(allOutput.includes("Checking cache at"), "Prints checking line"); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Shows resolved ui5DataDir"); + + // Listing shows absolute paths + const expectedFrameworkAbs = path.join(TEST_UI5_DATA_DIR, "framework"); + const expectedBuildAbs = path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7"); + t.true(allOutput.includes(expectedFrameworkAbs), "Shows absolute framework path"); + t.true(allOutput.includes(expectedBuildAbs), "Shows absolute build cache path"); + + // Labels and detail + t.true(allOutput.includes("UI5 Framework packages"), "Shows framework label"); + t.true(allOutput.includes("Build cache (DB)"), "Shows build cache label"); + t.true(allOutput.includes("340 files"), "Shows framework file count"); + t.true(allOutput.includes("8.0 MB"), "Shows build cache pre-clean size"); + t.false(allOutput.includes("7.0 MB"), "Does not show VACUUM-freed size (pre-clean size reused)"); + t.false(allOutput.includes("Total:"), "Does not show total line"); + + // Success + t.true(allOutput.includes("Cleaned UI5 Framework packages and Build cache (DB)"), + "Shows success summary"); +}); + +test.serial("ui5 cache clean: user cancels", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework", count: 10}); + buildCacheGetCacheInfo.resolves(null); + + yesnoStub.resolves(false); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when user cancels"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when user cancels"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); + t.false(allOutput.includes("Success"), "Does not show success message"); +}); + +test.serial("ui5 cache clean: framework only", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework", count: 1}); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves({path: "framework", count: 1}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1 file"), "Uses singular 'file'"); + t.false(allOutput.includes("Build cache (DB)"), "Does not mention build cache"); + t.true(allOutput.includes("Cleaned UI5 Framework packages"), "Success mentions framework only"); +}); + +test.serial("ui5 cache clean: build only", async (t) => { + const {cache, argv, stderrWriteStub, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.false(allOutput.includes("UI5 Framework packages"), "Does not mention framework"); + t.true(allOutput.includes("50.0 KB"), "Shows build cache size"); + t.true(allOutput.includes("Cleaned Build cache (DB)"), "Success mentions build cache only"); +}); + +test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("50.0 KB"), "Shows KB format"); +}); + +test.serial("ui5 cache clean: formats GB sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("2.5 GB"), "Shows GB format"); +}); + +test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework", count: 100}); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", count: 100}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --yes"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache called"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache called"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Success"), "Shows success message"); +}); + +test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; + + frameworkCacheIsFrameworkLocked.resolves(true); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Error:"), "Shows Error"); + t.true(allOutput.includes("currently locked by an active operation"), "Shows lock message"); + t.false(allOutput.includes("Success"), "Does not show success"); + t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo not called when locked"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when locked"); + t.is(process.exitCode, 1, "Exit code should be 1"); +}); + +test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, + buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; + + frameworkCacheIsFrameworkLocked.resolves(true); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Error:"), "Shows Error even with --yes"); + t.false(allOutput.includes("Success"), "Does not show success"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when locked"); + t.is(process.exitCode, 1, "Exit code should be 1"); +}); diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index e2d9bff9a6a..cd2e8246cbd 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -550,6 +550,49 @@ 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 {is_populated: isPopulated} = + this.#db.prepare(`SELECT EXISTS(SELECT 1 FROM ${table} LIMIT 1) as is_populated`).get(); + if (isPopulated) { + return true; + } + } + return false; + } + /** * Closes the database connection */ @@ -563,4 +606,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..99455f84886 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,5 +1,6 @@ import path from "node:path"; import os from "node:os"; +import {access} from "node:fs/promises"; import Configuration from "../../config/Configuration.js"; import {getLogger} from "@ui5/logger"; import BuildCacheStorage from "./BuildCacheStorage.js"; @@ -384,4 +385,78 @@ 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}|null>} Build cache info or null + */ + static async getCacheInfo(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); + + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return null; + } + + try { + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const size = storage.getDatabaseSize(); + return { + path: `buildCache/${CACHE_VERSION}`, + size, + }; + } + } 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, size: number}|null>} Removal result or null + */ + static async cleanCache(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); + + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return null; + } + + try { + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const freedSize = storage.clearAllRecords(); + return { + path: `buildCache/${CACHE_VERSION}`, + size: freedSize, + }; + } + } finally { + storage.close(); + } + } catch { + // Skip if database can't be cleared + } + return null; + } } diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index e13dea7f6e0..6f155ad0799 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -2,6 +2,7 @@ import path from "node:path"; import {mkdirp} from "../utils/fs.js"; import {promisify} from "node:util"; import {getLogger} from "@ui5/logger"; +import {LOCK_STALE_MS, getFrameworkLockDir} from "./_frameworkPaths.js"; const log = getLogger("ui5Framework:Installer"); // File name must not start with one or multiple dots and should not contain characters other than: @@ -22,7 +23,7 @@ class AbstractInstaller { if (!ui5DataDir) { throw new Error(`Installer: Missing parameter "ui5DataDir"`); } - this._lockDir = path.join(ui5DataDir, "framework", "locks"); + this._lockDir = getFrameworkLockDir(ui5DataDir); } async _synchronize(lockName, callback) { @@ -36,7 +37,7 @@ class AbstractInstaller { log.verbose("Locking " + lockPath); await lock(lockPath, { wait: 10000, - stale: 60000, + stale: LOCK_STALE_MS, retries: 10 }); try { diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js new file mode 100644 index 00000000000..fd1dede136d --- /dev/null +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -0,0 +1,61 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import {promisify} from "node:util"; + +// Directory name for framework packages within ui5DataDir +export const FRAMEWORK_DIR_NAME = "framework"; + +// Lockfile staleness threshold — must match the value used by AbstractInstaller#_synchronize +export const LOCK_STALE_MS = 60000; + +/** + * Resolve the absolute path to the framework directory within a UI5 data directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the framework directory + */ +export function getFrameworkDir(ui5DataDir) { + return path.join(ui5DataDir, FRAMEWORK_DIR_NAME); +} + +/** + * Resolve the absolute path to the framework locks directory within a UI5 data directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the framework locks directory + */ +export function getFrameworkLockDir(ui5DataDir) { + return path.join(ui5DataDir, FRAMEWORK_DIR_NAME, "locks"); +} + +/** + * Check whether any active (non-stale) lockfiles exist in the given locks directory, + * indicating an ongoing download or installation. + * + * @param {string} lockDir Absolute path to a locks directory + * @returns {Promise} True if any non-stale lockfiles are held + */ +export async function hasActiveLocks(lockDir) { + let entries; + try { + entries = await fs.readdir(lockDir); + } catch { + return false; + } + + const lockFiles = entries.filter((name) => name.endsWith(".lock")); + if (lockFiles.length === 0) { + return false; + } + + const {default: lockfile} = await import("lockfile"); + const check = promisify(lockfile.check); + for (const lockFileName of lockFiles) { + const lockPath = path.join(lockDir, lockFileName); + const isLocked = await check(lockPath, {stale: LOCK_STALE_MS}); + if (isLocked) { + return true; + } + } + return false; +} diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js new file mode 100644 index 00000000000..ac610ab82e3 --- /dev/null +++ b/packages/project/lib/ui5Framework/cache.js @@ -0,0 +1,98 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + FRAMEWORK_DIR_NAME, + getFrameworkDir, + getFrameworkLockDir, + hasActiveLocks, +} from "./_frameworkPaths.js"; + +/** + * Count all files in a directory tree recursively. + * Returns 0 if the directory does not exist or any entry is unreadable. + * + * @param {string} dirPath Absolute path to directory + * @returns {Promise} Total file count + */ +async function countFiles(dirPath) { + let total = 0; + let entries; + try { + entries = await fs.readdir(dirPath, {withFileTypes: true}); + } catch { + return 0; + } + for (const entry of entries) { + if (entry.isDirectory()) { + total += await countFiles(path.join(dirPath, entry.name)); + } else { + total++; + } + } + return total; +} + +/** + * Get framework cache info. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, count: number}|null>} Framework cache info or null + */ +export async function getCacheInfo(ui5DataDir) { + const frameworkDir = getFrameworkDir(ui5DataDir); + try { + await fs.access(frameworkDir); + const count = await countFiles(frameworkDir); + if (count > 0) { + return { + path: FRAMEWORK_DIR_NAME, + count, + }; + } + } catch { + // Directory doesn't exist + } + return null; +} + +/** + * Check whether an active (non-stale) framework lock is currently held, + * indicating an ongoing download or installation. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise} True if an active lock is held + */ +export async function isFrameworkLocked(ui5DataDir) { + return hasActiveLocks(getFrameworkLockDir(ui5DataDir)); +} + +/** + * Clean framework cache directory. + * + * Checks for active lockfiles before removing the directory to prevent + * deleting files while a download is in progress. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, count: number}|null>} Removal result or null + * @throws {Error} If a framework operation is currently active (active lockfiles detected) + */ +export async function cleanCache(ui5DataDir) { + const frameworkDir = getFrameworkDir(ui5DataDir); + const count = await countFiles(frameworkDir); + if (count === 0) { + return null; + } + + if (await hasActiveLocks(getFrameworkLockDir(ui5DataDir))) { + throw new Error( + "Framework cache is currently locked by an active operation. " + + "Please wait for it to finish and try again." + ); + } + + await fs.rm(frameworkDir, {recursive: true, force: true}); + return { + path: FRAMEWORK_DIR_NAME, + count, + }; +} diff --git a/packages/project/lib/ui5Framework/maven/Installer.js b/packages/project/lib/ui5Framework/maven/Installer.js index 2c8e45fb7f6..008ca0290e5 100644 --- a/packages/project/lib/ui5Framework/maven/Installer.js +++ b/packages/project/lib/ui5Framework/maven/Installer.js @@ -8,6 +8,7 @@ import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; import SnapshotCache from "./SnapshotCache.js"; import {rmrf} from "../../utils/fs.js"; +import {getFrameworkDir} from "../_frameworkPaths.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); @@ -33,10 +34,10 @@ class Installer extends AbstractInstaller { constructor({ui5DataDir, snapshotEndpointUrlCb, snapshotCache = SnapshotCache.Default}) { super(ui5DataDir); - this._artifactsDir = path.join(ui5DataDir, "framework", "artifacts"); - this._packagesDir = path.join(ui5DataDir, "framework", "packages"); - this._metadataDir = path.join(ui5DataDir, "framework", "metadata"); - this._stagingDir = path.join(ui5DataDir, "framework", "staging"); + this._artifactsDir = path.join(getFrameworkDir(ui5DataDir), "artifacts"); + this._packagesDir = path.join(getFrameworkDir(ui5DataDir), "packages"); + this._metadataDir = path.join(getFrameworkDir(ui5DataDir), "metadata"); + this._stagingDir = path.join(getFrameworkDir(ui5DataDir), "staging"); this._snapshotCache = snapshotCache; this._snapshotEndpointUrlCb = snapshotEndpointUrlCb; diff --git a/packages/project/lib/ui5Framework/npm/Installer.js b/packages/project/lib/ui5Framework/npm/Installer.js index 40d1dae9814..1e9fa2b9b13 100644 --- a/packages/project/lib/ui5Framework/npm/Installer.js +++ b/packages/project/lib/ui5Framework/npm/Installer.js @@ -5,6 +5,7 @@ import {promisify} from "node:util"; import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; import {rmrf} from "../../utils/fs.js"; +import {getFrameworkDir} from "../_frameworkPaths.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); const rename = promisify(fs.rename); @@ -27,15 +28,15 @@ class Installer extends AbstractInstaller { throw new Error(`Installer: Missing parameter "cwd"`); } this._packagesDir = packagesDir ? - path.resolve(packagesDir) : path.join(ui5DataDir, "framework", "packages"); + path.resolve(packagesDir) : path.join(getFrameworkDir(ui5DataDir), "packages"); log.verbose(`Installing to: ${this._packagesDir}`); this._cwd = cwd; this._caCacheDir = cacheDir ? - path.resolve(cacheDir) : path.join(ui5DataDir, "framework", "cacache"); + path.resolve(cacheDir) : path.join(getFrameworkDir(ui5DataDir), "cacache"); this._stagingDir = stagingDir ? - path.resolve(stagingDir) : path.join(ui5DataDir, "framework", "staging"); + path.resolve(stagingDir) : path.join(getFrameworkDir(ui5DataDir), "staging"); } getRegistry() { diff --git a/packages/project/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..6df2cf03595 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.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.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..2faef12069f --- /dev/null +++ b/packages/project/test/lib/ui5framework/cache.js @@ -0,0 +1,143 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import os from "node:os"; +import {promisify} from "node:util"; +import lockfileLib from "lockfile"; +import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; + +const lockfileLock = promisify(lockfileLib.lock); +const lockfileUnlock = promisify(lockfileLib.unlock); + +test.beforeEach(async (t) => { + const testDir = path.join(os.tmpdir(), `ui5-framework-cache-test-${Date.now()}-${Math.random()}`); + await fs.mkdir(testDir, {recursive: true}); + 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.count, 1); +}); + +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: counts files recursively", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const subDir = path.join(frameworkDir, "packages"); + await fs.mkdir(subDir, {recursive: true}); + 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.is(result.count, 2); +}); + +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.count, 1); + + // 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); + t.is(result.count, 1); + + // Verify directory and subdirectories were removed + await t.throwsAsync(fs.access(frameworkDir)); +}); + +test("cleanCache: throws when active lockfiles exist", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const lockDir = path.join(frameworkDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + const lockPath = path.join(lockDir, "test-package.lock"); + await lockfileLock(lockPath, {stale: 60000}); + try { + const err = await t.throwsAsync(cleanCache(t.context.testDir)); + t.true(err.message.includes("currently locked by an active operation")); + } finally { + await lockfileUnlock(lockPath); + } +}); + +test("cleanCache: removes directory when lockfiles are stale", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const lockDir = path.join(frameworkDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + // Create a real lock with a very short stale threshold, then wait for it to expire. + // lockfile.check uses ctime — fs.utimes only changes mtime, so backdating mtime won't work. + const lockPath = path.join(lockDir, "stale-package.lock"); + await lockfileLock(lockPath, {stale: 50}); // stale after 50ms + await lockfileUnlock(lockPath); // unlock so ctime stops being "now" — file still exists on disk + // Wait long enough for the 50ms threshold to pass + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await cleanCache(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.count, 1); // only test.txt remains — stale lock file is deleted by lockfileUnlock + + await t.throwsAsync(fs.access(frameworkDir)); +});