diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index c8841bd6869..99e090c1f9f 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -16,14 +16,28 @@ There are possibly many versions of UI5 framework dependencies installed on your #### Resolution -Remove the `.ui5/framework/` directory from your user's home directory: +Use the dedicated cache clean command, which safely removes all cached data: ```sh -rm -rf ~/.ui5/framework/ +ui5 cache clean ``` +This will display the cache location, the amount of data that will be removed, and ask for confirmation before proceeding. To skip the confirmation prompt (e.g. in CI environments), use the `--yes` flag: + +```sh +ui5 cache clean --yes +``` + +The command removes two types of cached data: +- **UI5 Framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) +- **Build cache (DB)** — build data (`~/.ui5/buildCache/`) + Any missing framework dependencies will be downloaded again during the next UI5 CLI invocation. +::: info +If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5DataDir`, the cache will be cleaned from that location instead of `~/.ui5`. See [Changing UI5 CLI's Data Directory](#changing-ui5-clis-data-directory) below. +::: + ## Environment Variables ### Changing the Log Level diff --git a/package-lock.json b/package-lock.json index b126bfd3a86..4b9e98167e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18263,7 +18263,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" @@ -18661,6 +18662,7 @@ "express": "^4.22.2", "fresh": "^0.5.2", "graceful-fs": "^4.2.11", + "lockfile": "^1.0.4", "mime-types": "^2.1.35", "parseurl": "^1.3.3", "portscanner": "^2.2.0", diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js new file mode 100644 index 00000000000..0c7a96508ca --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -0,0 +1,260 @@ +import chalk from "chalk"; +import path from "node:path"; +import process from "node:process"; +import baseMiddleware from "../middlewares/base.js"; +import {resolveUi5DataDir} from "@ui5/project/utils/dataDir"; +import {getLockDir, hasActiveLocks} from "@ui5/project/utils/lock"; +import * as frameworkCache from "@ui5/project/ui5Framework/cache"; +import CacheManager from "@ui5/project/build/cache/CacheManager"; + +const cacheCommand = { + command: "cache", + describe: "Manage the UI5 CLI cache (downloaded framework packages and build data)", + middlewares: [baseMiddleware], + handler: handleCache +}; + +cacheCommand.builder = function(cli) { + return cli + .demandCommand(1, "Command required. Available command is 'clean'") + .command("clean", "Remove all cached UI5 data", { + handler: handleCache, + builder: function(yargs) { + return yargs + .option("yes", { + alias: "y", + describe: "Skip the confirmation prompt, e.g. for use in CI pipelines", + default: false, + type: "boolean", + }) + .example("$0 cache clean", + "Remove all cached UI5 data after 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") + .epilogue( + "The cache is stored in the UI5 data directory (default: ~/.ui5).\n" + + "Override the location with the UI5_DATA_DIR environment variable or\n" + + "the 'ui5DataDir' configuration option (see 'ui5 config --help').\n\n" + + "Two cache types are removed:\n" + + " UI5 Framework packages Downloaded UI5 library files " + + "(~/.ui5/framework/)\n" + + " Build cache (DB) build data " + + "(~/.ui5/buildCache/)" + ); + }, + middlewares: [baseMiddleware], + }); +}; + +const LABEL_FRAMEWORK = "UI5 Framework packages"; +const LABEL_BUILD = "Build cache (DB)"; +// Pad labels to equal width for two-column alignment +const LABEL_WIDTH = Math.max(LABEL_FRAMEWORK.length, LABEL_BUILD.length); + +/** + * Format a byte size as a human-readable string. + * + * @param {number} bytes Size in bytes + * @returns {string} Formatted size string + */ +function formatSize(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } else if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * Format framework cache stats as a human-readable detail string. + * E.g. "1,189 versions of 155 libraries" or "1 version of 1 library". + * + * @param {number} libraryCount + * @param {number} versionCount + * @returns {string} + */ +function formatFrameworkStats(libraryCount, versionCount) { + const v = `${versionCount.toLocaleString("en-US")} ${versionCount === 1 ? "version" : "versions"}`; + const l = `${libraryCount.toLocaleString("en-US")} ${libraryCount === 1 ? "library" : "libraries"}`; + return `${v} of ${l}`; +} + +/** + * Pad a label to the shared column width. + * + * @param {string} label + * @returns {string} + */ +function padLabel(label) { + return label.padEnd(LABEL_WIDTH); +} + +/** + * Display information about the cached data that will be removed, + * including the absolute paths and details about the framework and build caches. + * + * @param {*} data + * @param {object} data.frameworkInfo + * @param {object} data.buildInfo + * @param {string} data.frameworkAbsPath + * @param {string} data.buildAbsPath + * @param {number} data.buildPreSize + */ +async function displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, +}) { + // Display items that will be removed + process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); + if (frameworkInfo) { + const detail = formatFrameworkStats(frameworkInfo.libraryCount, frameworkInfo.versionCount); + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkAbsPath} (${detail})\n` + ); + } + if (buildInfo) { + const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_BUILD)} ${buildAbsPath} (${detail})\n` + ); + } + process.stderr.write("\n"); +} + +/** + * Display the result of the cache cleanup operation, + * including which caches were removed and their details. + * + * @param {object} data + * @param {object} data.frameworkResult + * @param {object} data.buildResult + * @param {string} data.frameworkAbsPath + * @param {string} data.buildAbsPath + * @param {number} data.buildPreSize + */ +async function displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, +}) { + process.stderr.write("\n"); + if (frameworkResult) { + const detail = formatFrameworkStats( + frameworkResult.libraryCount, + frameworkResult.versionCount, + ); + 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`, + ); +} + +/** + * Prompt the user for confirmation before proceeding with cache cleanup. + * + * @param {Yargs.Arguments} argv + * @returns {Promise} Confirmation result + */ +async function getConfirmation(argv) { + if (argv.yes) { + return true; + } + const {default: yesno} = await import("yesno"); + return yesno({ + question: "Do you want to continue? (y/N)", + defaultValue: false + }); +} + +async function handleCache(argv) { + // 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 resolveUi5DataDir(); + + // Abort early if a lock is active — before prompting the user + if (await hasActiveLocks(getLockDir(ui5DataDir))) { + process.stderr.write( + `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + + "Cannot clean the cache while it is in use. " + + "Please stop all running 'ui5 serve' or wait for 'ui5 build' processes to finish.\n" + ); + process.exitCode = 1; + return; + } + + // Inform the user immediately — getPackageStats may take a moment on a large cache + process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + + const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); + const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); + + // Compute absolute paths once — producers return relative sub-path segments + const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; + const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; + const buildPreSize = buildInfo?.size ?? 0; + + if (!frameworkInfo && !buildInfo) { + process.stderr.write("Nothing to clean\n"); + return; + } + + await displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + }); + + const confirmed = await getConfirmation(argv); + 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); + + await displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + }); +} + +export default cacheCommand; diff --git a/packages/cli/package.json b/packages/cli/package.json index 95b697a9f13..c7c3b67e21f 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..5261edcb871 --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,365 @@ +import test from "ava"; +import path from "node:path"; +import sinon from "sinon"; +import esmock from "esmock"; + +function getDefaultArgv() { + return { + "_": ["cache", "clean"], + "loglevel": "info", + "log-level": "info", + "logLevel": "info", + "perf": false, + "silent": false, + "$0": "ui5" + }; +} + +// Stable absolute path used as the resolved ui5DataDir in most tests +const TEST_UI5_DATA_DIR = path.resolve("/test/ui5/home"); + +// Typical framework stub result shape: { path, libraryCount, versionCount } +const FRAMEWORK_STUB = {path: "framework", libraryCount: 18, versionCount: 5}; + +test.beforeEach(async (t) => { + t.context.argv = getDefaultArgv(); + t.context.stderrWriteStub = sinon.stub(process.stderr, "write"); + + // Prevent real env var from leaking into tests + delete process.env.UI5_DATA_DIR; + + t.context.resolveUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); + t.context.hasActiveLocksStub = sinon.stub().resolves(false); + + t.context.frameworkCacheGetCacheInfo = sinon.stub(); + t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.buildCacheGetCacheInfo = sinon.stub(); + t.context.buildCacheCleanCache = sinon.stub(); + + t.context.yesnoStub = sinon.stub(); + + t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { + "@ui5/project/utils/dataDir": { + resolveUi5DataDir: t.context.resolveUi5DataDirStub, + }, + "@ui5/project/utils/lock": { + getLockDir: sinon.stub().callsFake((dir) => `${dir}/locks`), + hasActiveLocks: t.context.hasActiveLocksStub, + }, + "@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; + } + }, + "yesno": { + default: t.context.yesnoStub, + }, + }); +}); + +test.afterEach.always((t) => { + sinon.restore(); + esmock.purge(t.context.cache); + process.exitCode = undefined; + delete process.env.UI5_DATA_DIR; +}); + +// ─── Command structure ────────────────────────────────────────────────────── + +test("Command builder", async (t) => { + const cacheModule = await import("../../../../lib/cli/commands/cache.js"); + const cliStub = { + demandCommand: sinon.stub().returnsThis(), + command: sinon.stub().returnsThis(), + example: sinon.stub().returnsThis(), + }; + const result = cacheModule.default.builder(cliStub); + t.is(result, cliStub, "Builder returns cli instance"); + t.is(cliStub.demandCommand.callCount, 1, "demandCommand called once"); + t.is(cliStub.command.callCount, 1, "command called once"); + t.is(cliStub.example.callCount, 0, "example not called on parent command"); +}); + +test.serial("Command definition is correct", (t) => { + t.is(t.context.cache.command, "cache"); + t.is(t.context.cache.describe, + "Manage the UI5 CLI cache (downloaded framework packages and build data)"); + t.is(typeof t.context.cache.builder, "function"); + t.is(typeof t.context.cache.handler, "function"); +}); + +// ─── ui5DataDir resolution ────────────────────────────────────────────────── + +test.serial("ui5 cache clean: uses resolved path from resolveUi5DataDir", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, stderrWriteStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, + "getCacheInfo receives the path returned by resolveUi5DataDir"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Resolved ui5DataDir shown in checking line"); +}); + +test.serial("ui5 cache clean: relative path from config is resolved via resolveUi5DataDir", async (t) => { + const {cache, argv, resolveUi5DataDirStub, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo} = t.context; + + const resolvedPath = path.resolve(process.cwd(), "./custom-cache"); + resolveUi5DataDirStub.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 resolveUi5DataDir"); +}); + +// ─── Basic flow ───────────────────────────────────────────────────────────── + +test.serial("ui5 cache clean: nothing to clean", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Checking cache at"), "Prints checking line"); + t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache not called"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called"); +}); + +test.serial("ui5 cache clean: removes both entries and reports", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); + + yesnoStub.resolves(true); + + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 7 * 1024 * 1024}); // 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 + t.true(allOutput.includes("Checking cache at"), "Prints checking line"); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Shows resolved ui5DataDir"); + + // Absolute paths + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "framework")), "Shows absolute framework path"); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7")), "Shows absolute build path"); + + // New format: "5 versions of 18 libraries" + t.true(allOutput.includes("5 versions of 18 libraries"), "Shows new library stats format"); + + // Build cache size — pre-clean size reused (not VACUUM-freed 7 MB) + t.true(allOutput.includes("8.0 MB"), "Shows pre-clean build cache size"); + t.false(allOutput.includes("7.0 MB"), "Does not show VACUUM-freed size"); + + t.false(allOutput.includes("Total:"), "Does not show total line"); + t.true(allOutput.includes("Cleaned UI5 Framework packages and Build cache (DB)"), + "Shows success summary"); +}); + +test.serial("ui5 cache clean: user cancels", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(false); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when user cancels"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when user cancels"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); + t.false(allOutput.includes("Success"), "Does not show success message"); +}); + +test.serial("ui5 cache clean: framework only — formats library stats correctly", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, yesnoStub} = t.context; + + // Plural + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + let allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("5 versions of 18 libraries"), "Shows plural format"); + t.false(allOutput.includes("Build cache (DB)"), "Does not mention build cache"); + + // Singular — reset stubs + stderrWriteStub.resetHistory(); + const singleStub = {path: "framework", libraryCount: 1, versionCount: 1}; + frameworkCacheGetCacheInfo.resetBehavior(); + frameworkCacheCleanCache.resetBehavior(); + frameworkCacheGetCacheInfo.resolves(singleStub); + frameworkCacheCleanCache.resolves(singleStub); + + argv["yes"] = true; + await cache.handler(argv); + + allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1 version of 1 library"), "Uses singular 'version' and 'library'"); +}); + +test.serial("ui5 cache clean: thousands separator in library stats", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, yesnoStub} = t.context; + + const largeStub = {path: "framework", libraryCount: 155, versionCount: 1189}; + frameworkCacheGetCacheInfo.resolves(largeStub); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves(largeStub); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1,189 versions of 155 libraries"), + "Shows thousands separator for large counts"); +}); + +test.serial("ui5 cache clean: build only", async (t) => { + const {cache, argv, stderrWriteStub, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.false(allOutput.includes("UI5 Framework packages"), "Does not mention framework"); + t.true(allOutput.includes("50.0 KB"), "Shows build cache size"); + t.true(allOutput.includes("Cleaned Build cache (DB)"), "Success mentions build cache only"); +}); + +test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("50.0 KB"), "Shows KB format"); +}); + +test.serial("ui5 cache clean: formats GB sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("2.5 GB"), "Shows GB format"); +}); + +test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --yes"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache called"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache called"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Success"), "Shows success message"); +}); + +test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, hasActiveLocksStub} = t.context; + + hasActiveLocksStub.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 running"), "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, hasActiveLocksStub} = t.context; + + hasActiveLocksStub.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/BuildServer.js b/packages/project/lib/build/BuildServer.js index b736e3be6b0..a982b8978d5 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -1,9 +1,13 @@ import EventEmitter from "node:events"; +import {getRandomValues} from "node:crypto"; import {createReaderCollectionPrioritized} from "@ui5/fs/resourceFactory"; import BuildReader from "./BuildReader.js"; import WatchHandler from "./helpers/WatchHandler.js"; import {SourceChangedDuringBuildError} from "./cache/ProjectBuildCache.js"; +import {getLockDir, acquireLock} from "../utils/lock.js"; +import {resolveUi5DataDir} from "../utils/dataDir.js"; import {getLogger} from "@ui5/logger"; +import path from "node:path"; const log = getLogger("build:BuildServer"); // Debounce window for the `sourcesChanged` event so a burst of file changes @@ -53,6 +57,8 @@ class BuildServer extends EventEmitter { #allReader; #rootReader; #dependenciesReader; + #releaseLock; + #ui5DataDir; /** * Creates a new BuildServer instance @@ -64,12 +70,14 @@ class BuildServer extends EventEmitter { * @private * @param {@ui5/project/graph/ProjectGraph} graph Project graph containing all projects * @param {@ui5/project/build/ProjectBuilder} projectBuilder Builder instance for executing builds + * @param {string} [ui5DataDir] UI5 data directory to use for the build server */ - constructor(graph, projectBuilder) { + constructor(graph, projectBuilder, ui5DataDir) { super(); this.#graph = graph; this.#rootProjectName = graph.getRoot().getName(); this.#projectBuilder = projectBuilder; + this.#ui5DataDir = ui5DataDir; const buildServerInterface = { getReaderForProject: this.#getReaderForProject.bind(this), @@ -112,13 +120,15 @@ class BuildServer extends EventEmitter { * @param {boolean} initialBuildRootProject Whether to build the root project in the initial build * @param {string[]} initialBuildIncludedDependencies Project names to include in initial build * @param {string[]} initialBuildExcludedDependencies Project names to exclude from initial build + * @param {string} [ui5DataDir] UI5 data directory to use for the build server * @returns {Promise} Resolves once the watcher is ready */ static async create( graph, projectBuilder, - initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies + initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies, ui5DataDir ) { - const buildServer = new BuildServer(graph, projectBuilder); + const buildServer = new BuildServer(graph, projectBuilder, ui5DataDir); + await buildServer.#acquireLock(); await buildServer.#initWatcher(); buildServer.#enqueueInitialBuilds( initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies @@ -126,6 +136,16 @@ class BuildServer extends EventEmitter { return buildServer; } + async #acquireLock() { + const resolvedUi5DataDir = this.#ui5DataDir ?? await resolveUi5DataDir(); + const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); + // The lock has unique name, so multiple processes can run concurrently. + // Also multiple BuildServer instances in the + // same process can run concurrently without collisions. + const lockPath = path.join(getLockDir(resolvedUi5DataDir), `server-${process.pid}-${lockId}.lock`); + this.#releaseLock = await acquireLock(lockPath); + } + #enqueueInitialBuilds( initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies ) { @@ -171,6 +191,14 @@ class BuildServer extends EventEmitter { // (e.g. Force-mode stale-cache errors). Otherwise the SQLite handle leaks // and subsequent fs.rm of the cache directory fails with EBUSY on Windows. this.#projectBuilder.closeCacheManager(); + if (this.#releaseLock) { + // In case of exceptions during the BuildServer lifecycle, + // the locks will become stale at certain point and will be + // automatically cleaned up by the lock manager. + // Note: this is a safe guard against lock leaks, but + // for the sake of clarity, the locks should be released in a predictable manner and explicitly. + this.#releaseLock(); + } } } diff --git a/packages/project/lib/build/ProjectBuilder.js b/packages/project/lib/build/ProjectBuilder.js index 0065724c906..40c885436c8 100644 --- a/packages/project/lib/build/ProjectBuilder.js +++ b/packages/project/lib/build/ProjectBuilder.js @@ -5,6 +5,10 @@ import composeProjectList from "./helpers/composeProjectList.js"; import BuildContext from "./helpers/BuildContext.js"; import prettyHrtime from "pretty-hrtime"; import OutputStyleEnum from "./helpers/ProjectBuilderOutputStyle.js"; +import path from "node:path"; +import {getLockDir, acquireLock} from "../utils/lock.js"; +import {resolveUi5DataDir} from "../utils/dataDir.js"; +import {getRandomValues} from "node:crypto"; /** * @public @@ -13,7 +17,7 @@ import OutputStyleEnum from "./helpers/ProjectBuilderOutputStyle.js"; */ class ProjectBuilder { #log; - #buildIsRunning = false; + #buildLockRelease = null; /** * Build Configuration @@ -122,6 +126,7 @@ class ProjectBuilder { } this._graph = graph; + this._ui5DataDir = ui5DataDir; this._buildContext = new BuildContext(graph, taskRepository, buildConfig, {ui5DataDir}); this.#log = new BuildLogger("ProjectBuilder"); } @@ -135,7 +140,7 @@ class ProjectBuilder { * @throws {Error} If a build is currently running */ resourcesChanged(changes) { - if (this.#buildIsRunning) { + if (this.#buildLockRelease) { throw new Error(`Unable to safely propagate resource changes. Build is currently running.`); } return this._buildContext.propagateResourceChanges(changes); @@ -312,10 +317,15 @@ class ProjectBuilder { * @throws {Error} If a build is already running */ async #build(requestedProjects, projectBuiltCallback, signal) { - if (this.#buildIsRunning) { + if (this.#buildLockRelease) { throw new Error("A build is already running"); } - this.#buildIsRunning = true; + + const resolvedUi5DataDir = this._ui5DataDir ?? await resolveUi5DataDir(); + const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); + const lockPath = path.join(getLockDir(resolvedUi5DataDir), `build-${process.pid}-${lockId}.lock`); + this.#buildLockRelease = await acquireLock(lockPath); + let cleanupSigHooks; const pCacheWrites = []; try { @@ -408,7 +418,8 @@ class ProjectBuilder { this._deregisterCleanupSigHooks(cleanupSigHooks); } await this._executeCleanupTasks(); - this.#buildIsRunning = false; + this.#buildLockRelease(); + this.#buildLockRelease = null; } } diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index fc91a486888..3489afb9253 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -511,6 +511,46 @@ export default class BuildCacheStorage { return new Set(rows.map((row) => row.integrity)); } + /** + * Clears all records from all tables and runs VACUUM. + * Returns the number of bytes freed. + * + * @returns {number} Number of bytes freed + */ + clearAllRecords() { + const bytesBefore = this.getDatabaseSize(); + + this.#db.exec("BEGIN"); + this.#db.exec("DELETE FROM content"); + this.#db.exec("DELETE FROM index_cache"); + this.#db.exec("DELETE FROM stage_metadata"); + this.#db.exec("DELETE FROM task_metadata"); + this.#db.exec("DELETE FROM result_metadata"); + this.#db.exec("COMMIT"); + this.#db.exec("VACUUM"); + + const bytesAfter = this.getDatabaseSize(); + + return bytesBefore - bytesAfter; + } + + /** + * Checks if the database has any records in any table. + * + * @returns {boolean} True if there are any records + */ + hasRecords() { + const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; + for (const table of tables) { + const {is_populated: isPopulated} = + this.#db.prepare(`SELECT EXISTS(SELECT 1 FROM ${table} LIMIT 1) as is_populated`).get(); + if (isPopulated) { + return true; + } + } + return false; + } + /** * Closes the database connection */ @@ -525,4 +565,15 @@ export default class BuildCacheStorage { this.#db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); this.#db.close(); } + + /** + * Get the total size of the database file + * + * @returns {number} Database size in bytes + */ + getDatabaseSize() { + const pageCount = this.#db.prepare("PRAGMA page_count").get().page_count; + const pageSize = this.#db.prepare("PRAGMA page_size").get().page_size; + return pageCount * pageSize; + } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index e1e37a6f521..5c67bd94bcf 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,6 +1,6 @@ import path from "node:path"; -import os from "node:os"; -import Configuration from "../../config/Configuration.js"; +import {access} from "node:fs/promises"; +import {resolveUi5DataDir} from "../../utils/dataDir.js"; import {getLogger} from "@ui5/logger"; import BuildCacheStorage from "./BuildCacheStorage.js"; @@ -73,17 +73,9 @@ export default class CacheManager { */ static async create(cwd, {ui5DataDir} = {}) { if (!ui5DataDir) { - // ENV var should take precedence over the dataDir from the configuration. - ui5DataDir = process.env.UI5_DATA_DIR; - if (!ui5DataDir) { - const config = await Configuration.fromFile(); - ui5DataDir = config.getUi5DataDir(); - } - } - if (ui5DataDir) { - ui5DataDir = path.resolve(cwd, ui5DataDir); + ui5DataDir = await resolveUi5DataDir(); } else { - ui5DataDir = path.join(os.homedir(), ".ui5"); + ui5DataDir = path.resolve(cwd, ui5DataDir); } const cacheDir = path.join(ui5DataDir, "buildCache"); log.verbose(`Using build cache directory: ${cacheDir}`); @@ -337,4 +329,86 @@ export default class CacheManager { cacheManagerInstances.delete(this.#cacheDir); } } + + /** + * Checks if the cache database exists and is accessible for the given directory. + * + * @param {string} dbDir Path to DB + * @returns {Promise} True if the cache database exists and is accessible + */ + static async #isCacheDBAvailable(dbDir) { + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return false; + } + + return true; + } + + /** + * Get build cache info for the current version. + * + * Note: This is a static method because as the constructor (CacheManager and BuildCacheStorage) + * always creates a DB. Here we simply check for its existence and return the size if it exists. + * + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number}|null>} Build cache info or null + */ + static async getCacheInfo(ui5DataDir) { + const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); + const isAvailable = await CacheManager.#isCacheDBAvailable(dbDir); + if (!isAvailable) { + return null; + } + + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const size = storage.getDatabaseSize(); + return { + path: `buildCache/${CACHE_VERSION}`, + size, + }; + } + } finally { + storage.close(); + } + + return null; + } + + /** + * Clean build cache by clearing all records from SQLite database for the current version. + * + * Note: This is a static method because as the constructor (CacheManager and BuildCacheStorage) + * always creates a DB. Clean all records from the database only if such already is present. + * + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number}|null>} Removal result or null + */ + static async cleanCache(ui5DataDir) { + const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); + const isAvailable = await CacheManager.#isCacheDBAvailable(dbDir); + if (!isAvailable) { + return null; + } + + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const freedSize = storage.clearAllRecords(); + return { + path: `buildCache/${CACHE_VERSION}`, + size: freedSize, + }; + } + } finally { + storage.close(); + } + return null; + } } diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 653e6f7901b..6db240f5d8d 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -794,7 +794,7 @@ class ProjectGraph { default: BuildServer } = await import("../build/BuildServer.js"); return BuildServer.create(this, builder, - initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies); + initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies, ui5DataDir); } /** diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index 660cc78427e..ba661a205f7 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -2,8 +2,7 @@ import Module from "../Module.js"; import ProjectGraph from "../ProjectGraph.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:helpers:ui5Framework"); -import Configuration from "../../config/Configuration.js"; -import path from "node:path"; +import {resolveUi5DataDir} from "../../utils/dataDir.js"; class ProjectProcessor { constructor({libraryMetadata, graph, workspace}) { @@ -349,14 +348,7 @@ export default { } // ENV var should take precedence over the dataDir from the configuration. - let ui5DataDir = process.env.UI5_DATA_DIR; - if (!ui5DataDir) { - const config = await Configuration.fromFile(); - ui5DataDir = config.getUi5DataDir(); - } - if (ui5DataDir) { - ui5DataDir = path.resolve(cwd, ui5DataDir); - } + const ui5DataDir = await resolveUi5DataDir(); if (options.versionOverride) { version = await Resolver.resolveVersion(options.versionOverride, { diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index e13dea7f6e0..c53b24cffd3 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -1,7 +1,6 @@ import path from "node:path"; -import {mkdirp} from "../utils/fs.js"; -import {promisify} from "node:util"; import {getLogger} from "@ui5/logger"; +import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, acquireLock} from "../utils/lock.js"; const log = getLogger("ui5Framework:Installer"); // File name must not start with one or multiple dots and should not contain characters other than: @@ -22,29 +21,25 @@ class AbstractInstaller { if (!ui5DataDir) { throw new Error(`Installer: Missing parameter "ui5DataDir"`); } - this._lockDir = path.join(ui5DataDir, "framework", "locks"); + this._lockDir = getLockDir(ui5DataDir); } async _synchronize(lockName, callback) { - const { - default: lockfile - } = await import("lockfile"); - const lock = promisify(lockfile.lock); - const unlock = promisify(lockfile.unlock); const lockPath = this._getLockPath(lockName); - await mkdirp(this._lockDir); log.verbose("Locking " + lockPath); - await lock(lockPath, { - wait: 10000, - stale: 60000, - retries: 10 - }); + const releaseLock = await acquireLock(lockPath, {wait: 10000, retries: 10}); try { - const res = await callback(); - return res; + // Abort if cache cleanup is in progress. Checking after acquiring our lock + // ensures cleanCache's hasActiveLocks scan will see us if both run concurrently. + if (await hasActiveLocks(this._lockDir, {include: CLEANUP_LOCK_NAME})) { + throw new Error( + "Framework cache is currently being cleaned. " + + "Please wait for the cache clean operation to finish and try again." + ); + } + return callback(); } finally { - log.verbose("Unlocking " + lockPath); - await unlock(lockPath); + releaseLock(); } } diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js new file mode 100644 index 00000000000..3e756494673 --- /dev/null +++ b/packages/project/lib/ui5Framework/cache.js @@ -0,0 +1,135 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, acquireLock} from "../utils/lock.js"; + +const FRAMEWORK_DIR_NAME = "framework"; + +/** + * Count unique libraries and versions in the packages/ subdirectory. + * + * Library names are deduplicated globally: sap.m under @openui5 and @sapui5 counts + * as one library. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{libraries: number, versions: number}|null>} + * Null if the directory does not exist or contains no installed libraries. + */ +async function getPackageStats(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + try { + await fs.access(frameworkDir); + } catch { + return null; + } + + const packagesDir = path.join(frameworkDir, "packages"); + let projectDirs; + try { + projectDirs = await fs.readdir(packagesDir, {withFileTypes: true}); + } catch { + return null; + } + + const extractSubDir = (dirList) => { + return dirList.filter((e) => e.isDirectory()) + .map((currentDir) => { + try { + return fs.readdir(path.join(currentDir.parentPath, currentDir.name), {withFileTypes: true}); + } catch { + return; + } + }); + }; + + const libDirs = (await Promise.all(extractSubDir(projectDirs))).flat(); + const versionDirs = (await Promise.all(extractSubDir(libDirs))).flat(); + + const librarySet = new Set(libDirs.map((e) => e.name)); + const versionSet = new Set(versionDirs.map((e) => e.name)); + + return librarySet.size > 0 ? + {libraries: librarySet.size, versions: versionSet.size} : + null; +} + +/** + * Get framework cache info. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} + * Framework cache info, or null if no packages are installed. + */ +export async function getCacheInfo(ui5DataDir) { + const stats = await getPackageStats(ui5DataDir); + if (!stats) { + return null; + } + return { + path: FRAMEWORK_DIR_NAME, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; +} + +/** + * Clean framework cache directory. + * + * Acquires a cleanup lock before deletion so that concurrent installer + * processes see an active lock and wait rather than writing into a + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} + * Removal result, or null if nothing was installed. + * @throws {Error} If a framework operation is currently active (active lockfiles detected) + */ +export async function cleanCache(ui5DataDir) { + const stats = await getPackageStats(ui5DataDir); + if (!stats) { + return null; + } + + const lockDir = getLockDir(ui5DataDir); + const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + + // Acquire first, then check — ensures installers running concurrently will see + // the cleanup lock and abort before writing into a directory being deleted. + const releaseCleanupLock = await acquireLock(lockPath); + try { + if (await hasActiveLocks(lockDir, {exclude: CLEANUP_LOCK_NAME})) { + throw new Error( + "Framework cache is currently locked by an active operation. " + + "Please wait for it to finish and try again." + ); + } + + // Use cacache's own rm.all to clear the pacote download cache. + // This respects cacache's internal structure (content-v2/, index-v5/) + // and clears in-memory memoization, which a plain fs.rm would not do. + const caCacheDir = path.join(frameworkDir, "cacache"); + try { + await fs.access(caCacheDir); + const {rm: cacacheRm} = await import("cacache"); + await cacacheRm.all(caCacheDir); + } catch { + // cacache dir doesn't exist or cacache not available — no-op + } + + // Delete everything inside framework/ + const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); + await Promise.all(entries.map((entry) => { + const curDir = path.join(frameworkDir, entry.name); + return entry.isDirectory() ? + fs.rm(curDir, {recursive: true, force: true}) : + fs.unlink(curDir); + })); + } finally { + releaseCleanupLock(); + } + + return { + path: FRAMEWORK_DIR_NAME, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; +} diff --git a/packages/project/lib/utils/dataDir.js b/packages/project/lib/utils/dataDir.js new file mode 100644 index 00000000000..484b7bfda1b --- /dev/null +++ b/packages/project/lib/utils/dataDir.js @@ -0,0 +1,28 @@ +import path from "node:path"; +import os from "node:os"; +import Configuration from "../config/Configuration.js"; + +/** + * Resolves the UI5 data directory using the standard precedence chain: + *
    + *
  1. UI5_DATA_DIR environment variable
  2. + *
  3. ui5DataDir option from the configuration file (~/.ui5rc)
  4. + *
  5. Default: ~/.ui5
  6. + *
+ * + * Relative paths are resolved against cwd. + * This function always returns an absolute path — never undefined. + * + * @returns {Promise} Resolved absolute path to the UI5 data directory + */ +export async function resolveUi5DataDir() { + let ui5DataDir = process.env.UI5_DATA_DIR; + if (!ui5DataDir) { + const config = await Configuration.fromFile(); + ui5DataDir = config.getUi5DataDir(); + } + if (ui5DataDir) { + return path.resolve(process.cwd(), ui5DataDir); + } + return path.join(os.homedir(), ".ui5"); +} diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js new file mode 100644 index 00000000000..59020b21caf --- /dev/null +++ b/packages/project/lib/utils/lock.js @@ -0,0 +1,109 @@ +import path from "node:path"; +import {readdir} from "node:fs/promises"; +import {mkdir} from "node:fs/promises"; +import {promisify} from "node:util"; +import lockfile from "lockfile"; + +/** + * Lockfile staleness threshold shared across all lock users (framework installer, + * cache cleanup, server, build). Must be consistent so that hasActiveLocks() + * and individual lock acquisitions agree on when a lock is stale. + */ +export const LOCK_STALE_MS = 60000; + +/** + * Lock file name held exclusively by ui5 cache clean for the full + * deletion duration. Installers check for this lock before acquiring a per-package + * lock so that cleanup in progress is detected. + */ +export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; + +/** + * Resolve the absolute path to the shared locks directory within a UI5 data directory. + * + * All process-coordination lock files (framework installer, cache cleanup, server, + * build) live here. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the locks directory (~/.ui5/locks/) + */ +export function getLockDir(ui5DataDir) { + return path.join(ui5DataDir, "locks"); +} + +/** + * Check whether any active (non-stale) lockfiles exist in the given locks directory, + * indicating an ongoing download, installation, build, or server process. + * + * @param {string} lockDir Absolute path to a locks directory + * @param {object} [options] + * @param {string|string[]} [options.include] Only check these lock file names (allowlist). + * If provided, only files in this list are considered. + * @param {string|string[]} [options.exclude] Lock file names to skip (denylist). + * If provided, these files are excluded from the scan. + * @returns {Promise} True if any matching non-stale lockfiles are held + */ +export async function hasActiveLocks(lockDir, {include, exclude} = {}) { + let entries; + try { + entries = await readdir(lockDir); + } catch { + return false; + } + + const includeSet = include ? new Set([].concat(include)) : null; + const excludeSet = exclude ? new Set([].concat(exclude)) : null; + + const lockFiles = entries.filter((name) => { + if (!name.endsWith(".lock")) return false; + if (includeSet && !includeSet.has(name)) return false; + if (excludeSet && excludeSet.has(name)) return false; + return true; + }); + + if (lockFiles.length === 0) { + return false; + } + + const check = promisify(lockfile.check); + const unlock = promisify(lockfile.unlock); + + for (const lockFileName of lockFiles) { + const lockPath = path.join(lockDir, lockFileName); + const isLocked = await check(lockPath, {stale: LOCK_STALE_MS}); + if (isLocked) { + return true; + } + + // This is a stale lock file that no longer serves its purpose. + // It's maybe there as some process crashed and didn't clean up after itself. + // We can try to remove it. + await unlock(lockPath).catch(() => {}); + } + return false; +} + +/** + * Acquire a lockfile and return a release function. + * + * The returned release function must be called to release the lock on graceful + * shutdown. On abnormal process exit (signals), lockfile's own + * signal-exit handler handles cleanup automatically. + * + * Creates the lock directory if it does not exist. + * + * @param {string} lockPath Absolute path to the lock file + * @param {object} [options] + * @param {number} [options.wait] Milliseconds to wait for the lock before giving up + * @param {number} [options.retries] Number of times to retry acquiring the lock + * @returns {Promise} Resolves with a synchronous release() function + */ +export async function acquireLock(lockPath, {wait, retries} = {}) { + await mkdir(path.dirname(lockPath), {recursive: true}); + await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS, wait, retries}); + return () => { + // unlockSync is used here as in some cases the process may be exiting + // and async cleanup may not complete in time. + lockfile.unlockSync(lockPath); + }; +} diff --git a/packages/project/package.json b/packages/project/package.json index 70d95ba44de..744313fe350 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -20,12 +20,16 @@ "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", + "./utils/dataDir": "./lib/utils/dataDir.js", + "./utils/lock": "./lib/utils/lock.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/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index fbd63f0e11f..ab2d1be7ddc 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -1035,6 +1035,26 @@ function getTmpPath(folderName) { return fileURLToPath(new URL(`../../tmp/BuildServer/${folderName}`, import.meta.url)); } +// ─── Serve lock ─────────────────────────────────────────────────────────────── + +test.serial("serve(): acquires server-{pid}-{hex} lock and releases it on destroy", async (t) => { + const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); + await fixtureTester.serveProject(); + + const lockDir = path.join(fixtureTester.ui5DataDir, "locks"); + const lockFiles = await fs.readdir(lockDir); + const serverLocks = lockFiles.filter( + (f) => f.match(new RegExp(`^server-${process.pid}-[0-9a-f]+\\.lock$`))); + t.is(serverLocks.length, 1, "exactly one server lock file exists while server is running"); + + await fixtureTester.buildServer.destroy(); + + const lockFilesAfter = await fs.readdir(lockDir).catch(() => []); + const serverLocksAfter = lockFilesAfter.filter( + (f) => f.match(new RegExp(`^server-${process.pid}-[0-9a-f]+\\.lock$`))); + t.is(serverLocksAfter.length, 0, "lock file removed after buildServer.destroy()"); +}); + async function rmrf(dirPath) { return fs.rm(dirPath, {recursive: true, force: true, maxRetries: 3, retryDelay: 200}); } diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index e79e147ef29..f5542781af7 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -43,6 +43,13 @@ test.beforeEach(async (t) => { // BuildReader is constructed in the BuildServer constructor but not exercised here. "../../../lib/build/BuildReader.js": class BuildReader {}, "../../../lib/build/helpers/WatchHandler.js": FakeWatchHandler, + "../../../lib/utils/lock.js": { + getLockDir: sinon.stub().returns("/fake/locks"), + acquireLock: sinon.stub().resolves(() => {}) + }, + "../../../lib/utils/dataDir.js": { + resolveUi5DataDir: sinon.stub().resolves("/fake/ui5data") + }, })).default; t.context.BuildServer = BuildServer; t.context.SOURCES_CHANGED_DEBOUNCE_MS = BuildServer.__internals__.SOURCES_CHANGED_DEBOUNCE_MS; diff --git a/packages/project/test/lib/build/ProjectBuilder.js b/packages/project/test/lib/build/ProjectBuilder.js index 1946b1d4f38..fdecd4bedd1 100644 --- a/packages/project/test/lib/build/ProjectBuilder.js +++ b/packages/project/test/lib/build/ProjectBuilder.js @@ -81,7 +81,15 @@ test.beforeEach(async (t) => { }) }; - t.context.ProjectBuilder = await esmock("../../../lib/build/ProjectBuilder.js"); + t.context.ProjectBuilder = await esmock("../../../lib/build/ProjectBuilder.js", { + "../../../lib/utils/lock.js": { + getLockDir: sinon.stub().returns("/fake/locks"), + acquireLock: sinon.stub().resolves(() => {}) + }, + "../../../lib/utils/dataDir.js": { + resolveUi5DataDir: sinon.stub().resolves("/fake/ui5data") + }, + }); }); test.afterEach.always((t) => { diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index e02d0850d48..f1c1e944a4b 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -123,13 +123,10 @@ test.serial("hasResourceForStage throws without integrity", async (t) => { test.serial("create() returns singleton per cache directory", async (t) => { const testDir = getUniqueTestDir(); - process.env.UI5_DATA_DIR = testDir; const CacheManager = await esmock("../../../../lib/build/cache/CacheManager.js", { - "../../../../lib/config/Configuration.js": { - default: { - fromFile: sinon.stub().resolves({getUi5DataDir: () => null}) - } + "../../../../lib/utils/dataDir.js": { + resolveUi5DataDir: sinon.stub().resolves(testDir) } }); @@ -203,3 +200,79 @@ test.serial("transaction: throwing rolls back metadata and content writes", asyn "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/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index 93096d50109..19ac0b7036d 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -135,7 +135,9 @@ test.beforeEach(async (t) => { "../../../../lib/graph/Module.js": t.context.Module, "../../../../lib/ui5Framework/Openui5Resolver.js": t.context.Openui5Resolver, "../../../../lib/ui5Framework/Sapui5Resolver.js": t.context.Sapui5Resolver, - "../../../../lib/config/Configuration.js": t.context.Configuration + "../../../../lib/utils/dataDir.js": { + resolveUi5DataDir: sinon.stub().resolves(path.join(fakeBaseDir, "homedir", ".ui5")) + } }); t.context.projectGraphBuilder = await esmock.p("../../../../lib/graph/projectGraphBuilder.js", { @@ -759,6 +761,9 @@ defineErrorTest( frameworkName: "OpenUI5", failMetadata: true, failExtract: true, + // When both manifest fetch and extraction fail simultaneously, which error surfaces first + // depends on microtask scheduling and is not deterministic across Node versions. Both are + // valid: accept either "Failed to read manifest" or "Failed to extract package". expectedErrorMessage: `Resolution of framework libraries failed with errors: 1. Failed to resolve library sap.ui.lib1: Failed to read manifest of @openui5/sap.ui.lib1@1.75.0 2. Failed to resolve library sap.ui.lib4: Failed to read manifest of @openui5/sap.ui.lib4@1.75.0` diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.js b/packages/project/test/lib/graph/helpers/ui5Framework.js index b134ac187ac..649f7c45bc7 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.js @@ -54,19 +54,15 @@ test.beforeEach(async (t) => { t.context.Sapui5MavenSnapshotResolverResolveVersionStub = sinon.stub(); t.context.Sapui5MavenSnapshotResolverStub.resolveVersion = t.context.Sapui5MavenSnapshotResolverResolveVersionStub; - t.context.getUi5DataDirStub = sinon.stub().returns(undefined); - - t.context.ConfigurationStub = { - fromFile: sinon.stub().resolves({ - getUi5DataDir: t.context.getUi5DataDirStub - }) - }; + t.context.resolveUi5DataDirStub = sinon.stub().resolves(undefined); t.context.ui5Framework = await esmock.p("../../../../lib/graph/helpers/ui5Framework.js", { "@ui5/logger": ui5Logger, "../../../../lib/ui5Framework/Sapui5Resolver.js": t.context.Sapui5ResolverStub, "../../../../lib/ui5Framework/Sapui5MavenSnapshotResolver.js": t.context.Sapui5MavenSnapshotResolverStub, - "../../../../lib/config/Configuration.js": t.context.ConfigurationStub, + "../../../../lib/utils/dataDir.js": { + resolveUi5DataDir: t.context.resolveUi5DataDirStub + }, }); t.context.utils = t.context.ui5Framework._utils; }); @@ -1108,6 +1104,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from env var", async (t) process.env.UI5_DATA_DIR = "./ui5-data-dir-from-env-var"; const expectedUi5DataDir = path.resolve(dependencyTree.path, "./ui5-data-dir-from-env-var"); + t.context.resolveUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); @@ -1122,7 +1119,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from env var", async (t) }); test.serial("enrichProjectGraph should use UI5 data dir from configuration", async (t) => { - const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, getUi5DataDirStub} = t.context; + const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, resolveUi5DataDirStub} = t.context; const dependencyTree = { id: "test1", @@ -1161,9 +1158,8 @@ test.serial("enrichProjectGraph should use UI5 data dir from configuration", asy const provider = new DependencyTreeProvider({dependencyTree}); const projectGraph = await projectGraphBuilder(provider); - getUi5DataDirStub.returns("./ui5-data-dir-from-config"); - const expectedUi5DataDir = path.resolve(dependencyTree.path, "./ui5-data-dir-from-config"); + resolveUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); @@ -1178,7 +1174,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from configuration", asy }); test.serial("enrichProjectGraph should use absolute UI5 data dir from configuration", async (t) => { - const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, getUi5DataDirStub} = t.context; + const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, resolveUi5DataDirStub} = t.context; const dependencyTree = { id: "test1", @@ -1217,9 +1213,8 @@ test.serial("enrichProjectGraph should use absolute UI5 data dir from configurat const provider = new DependencyTreeProvider({dependencyTree}); const projectGraph = await projectGraphBuilder(provider); - getUi5DataDirStub.returns("/absolute-ui5-data-dir-from-config"); - const expectedUi5DataDir = path.resolve("/absolute-ui5-data-dir-from-config"); + resolveUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 684e8634a84..fe73b53e0e4 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, 18); }); // 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..699fd309f0a --- /dev/null +++ b/packages/project/test/lib/ui5framework/cache.js @@ -0,0 +1,167 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +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); + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "ui5framework-cache"); + +test.beforeEach(async (t) => { + const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(testDir, {recursive: true}); + t.context.testDir = testDir; +}); + + +// ─── Helper ────────────────────────────────────────────────────────────────── + +async function mkPackage(testDir, project, library, version) { + const dir = path.join(testDir, "framework", "packages", project, library, version); + await fs.mkdir(dir, {recursive: true}); + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({name: `${project}/${library}`, version})); +} + +// ─── getCacheInfo ───────────────────────────────────────────────────────────── + +test("getCacheInfo: non-existent framework directory returns null", async (t) => { + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: framework dir exists but no packages/ subdir returns null", async (t) => { + await fs.mkdir(path.join(t.context.testDir, "framework", "cacache"), {recursive: true}); + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: packages/ exists but is empty returns null", async (t) => { + await fs.mkdir(path.join(t.context.testDir, "framework", "packages"), {recursive: true}); + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: counts libraries and versions", async (t) => { + // 2 unique library names across 2 scopes, 3 unique versions + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.148.0"); + await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.libraryCount, 2); // sap.m counted once (deduplicated across scopes) + t.is(result.versionCount, 3); // 1.120.0, 1.148.0, 1.38.1 +}); + +test("getCacheInfo: deduplicates versions across libraries", async (t) => { + // Both libraries have 1.120.0 — version should count once + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.libraryCount, 2); + t.is(result.versionCount, 1); // 1.120.0 deduplicated +}); + +test("getCacheInfo: single library and version", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.libraryCount, 1); + t.is(result.versionCount, 1); +}); + +// ─── cleanCache ─────────────────────────────────────────────────────────────── + +test("cleanCache: returns null for non-existent framework directory", async (t) => { + const result = await cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: returns null when packages/ has no installed libraries", async (t) => { + await fs.mkdir(path.join(t.context.testDir, "framework", "packages"), {recursive: true}); + const result = await cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: removes framework directory and returns stats", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.148.0"); + + const frameworkDir = path.join(t.context.testDir, "framework"); + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.libraryCount, 2); + t.is(result.versionCount, 2); // 1.120.0, 1.148.0 + + // packages/ is removed so a subsequent getCacheInfo returns null + const packagesDir = path.join(frameworkDir, "packages"); + await t.throwsAsync(fs.access(packagesDir)); +}); + +test("cleanCache: removes directory with multiple scopes", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); + + const frameworkDir = path.join(t.context.testDir, "framework"); + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.is(result.libraryCount, 1); // sap.m deduplicated + t.is(result.versionCount, 2); + + // packages/ is removed so a subsequent getCacheInfo returns null + const packagesDir = path.join(frameworkDir, "packages"); + await t.throwsAsync(fs.access(packagesDir)); +}); + +test("cleanCache: throws when active lockfiles exist", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const lockDir = path.join(t.context.testDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + + 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) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const lockDir = path.join(t.context.testDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + + // 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 + await new Promise((resolve) => setTimeout(resolve, 100)); + + const frameworkDir = path.join(t.context.testDir, "framework"); + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.libraryCount, 1); + t.is(result.versionCount, 1); + + // packages/ is removed so a subsequent getCacheInfo returns null + const packagesDir = path.join(frameworkDir, "packages"); + await t.throwsAsync(fs.access(packagesDir)); +}); diff --git a/packages/project/test/lib/ui5framework/maven/Installer.js b/packages/project/test/lib/ui5framework/maven/Installer.js index c07e9e204bc..3d5fe4cf65e 100644 --- a/packages/project/test/lib/ui5framework/maven/Installer.js +++ b/packages/project/test/lib/ui5framework/maven/Installer.js @@ -36,13 +36,11 @@ test.beforeEach(async (t) => { }); t.context.AbstractInstaller = await esmock.p("../../../../lib/ui5Framework/AbstractInstaller.js", { - "../../../../lib/utils/fs.js": { - mkdirp: t.context.mkdirpStub, - rmrf: t.context.rmrfStub - }, - "lockfile": { - lock: t.context.lockStub, - unlock: t.context.unlockStub + "../../../../lib/utils/lock.js": { + getLockDir: sinon.stub().callsFake((dir) => path.join(dir, "locks")), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + acquireLock: sinon.stub().callsFake(async () => () => {}) } }); @@ -80,7 +78,7 @@ test.serial("constructor", (t) => { t.is(installer._packagesDir, path.join("/ui5Data/", "framework", "packages")); t.is(installer._stagingDir, path.join("/ui5Data/", "framework", "staging")); t.is(installer._metadataDir, path.join("/ui5Data/", "framework", "metadata")); - t.is(installer._lockDir, path.join("/ui5Data/", "framework", "locks")); + t.is(installer._lockDir, path.join("/ui5Data/", "locks")); }); test.serial("constructor requires 'ui5DataDir'", (t) => { @@ -203,7 +201,7 @@ test.serial("_getLockPath", (t) => { const lockPath = installer._getLockPath("package-@openui5/sap.ui.lib1@1.2.3-SNAPSHOT"); - t.is(lockPath, path.join("/ui5Data/", "framework", "locks", "package-@openui5-sap.ui.lib1@1.2.3-SNAPSHOT.lock")); + t.is(lockPath, path.join("/ui5Data/", "locks", "package-@openui5-sap.ui.lib1@1.2.3-SNAPSHOT.lock")); }); test.serial("readJson", async (t) => { diff --git a/packages/project/test/lib/ui5framework/npm/Installer.js b/packages/project/test/lib/ui5framework/npm/Installer.js index c06b36ae33d..fc64b665f53 100644 --- a/packages/project/test/lib/ui5framework/npm/Installer.js +++ b/packages/project/test/lib/ui5framework/npm/Installer.js @@ -10,18 +10,18 @@ test.beforeEach(async (t) => { t.context.rmrfStub = sinon.stub().resolves(); t.context.lockStub = sinon.stub(); - t.context.unlockStub = sinon.stub(); + t.context.unlockSyncStub = sinon.stub(); + // Configure stubs to call back immediately so promisify-wrapped lock resolves + t.context.renameStub = sinon.stub().yieldsAsync(); t.context.statStub = sinon.stub().yieldsAsync(); t.context.AbstractResolver = await esmock.p("../../../../lib/ui5Framework/AbstractInstaller.js", { - "../../../../lib/utils/fs.js": { - mkdirp: t.context.mkdirpStub, - rmrf: t.context.rmrfStub - }, - "lockfile": { - lock: t.context.lockStub, - unlock: t.context.unlockStub + "../../../../lib/utils/lock.js": { + getLockDir: sinon.stub().callsFake((dir) => path.join(dir, "locks")), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + acquireLock: sinon.stub().callsFake(async () => () => {}) } }); t.context.Installer = await esmock.p("../../../../lib/ui5Framework/npm/Installer.js", { @@ -52,7 +52,7 @@ test.serial("Installer: constructor", (t) => { }); t.true(installer instanceof Installer, "Constructor returns instance of class"); t.is(installer._packagesDir, path.join("/ui5Data/", "framework", "packages")); - t.is(installer._lockDir, path.join("/ui5Data/", "framework", "locks")); + t.is(installer._lockDir, path.join("/ui5Data/", "locks")); t.is(installer._stagingDir, path.join("/ui5Data/", "framework", "staging")); }); @@ -120,7 +120,7 @@ test.serial("Installer: _getLockPath", (t) => { const lockPath = installer._getLockPath("lo/ck-n@me"); - t.is(lockPath, path.join("/ui5Data/", "framework", "locks", "lo-ck-n@me.lock")); + t.is(lockPath, path.join("/ui5Data/", "locks", "lo-ck-n@me.lock")); }); test.serial("Installer: _getLockPath with illegal characters", (t) => { @@ -314,11 +314,7 @@ test.serial("Installer: _synchronize", async (t) => { ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const getLockPathStub = sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); - const callback = sinon.stub().resolves(); await installer._synchronize("lock/name", callback); @@ -326,53 +322,29 @@ test.serial("Installer: _synchronize", async (t) => { t.is(getLockPathStub.callCount, 1, "_getLockPath should be called once"); t.is(getLockPathStub.getCall(0).args[0], "lock/name", "_getLockPath should be called with expected args"); - - t.is(t.context.mkdirpStub.callCount, 1, "_mkdirp should be called once"); - t.deepEqual(t.context.mkdirpStub.getCall(0).args, [path.join("/ui5Data/", "framework", "locks")], - "_mkdirp should be called with expected args"); - - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.lockStub.getCall(0).args[0], "/locks/lockfile.lock", - "lock should be called with expected path"); - t.deepEqual(t.context.lockStub.getCall(0).args[1], {wait: 10000, stale: 60000, retries: 10}, - "lock should be called with expected options"); - - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); - t.is(t.context.unlockStub.getCall(0).args[0], "/locks/lockfile.lock", - "unlock should be called with expected path"); - t.is(callback.callCount, 1, "callback should be called once"); - - t.true(t.context.lockStub.calledBefore(callback), "Lock should be called before invoking the callback"); - t.true(t.context.unlockStub.calledAfter(callback), "Unlock should be called after invoking the callback"); }); test.serial("Installer: _synchronize should unlock when callback promise has resolved", async (t) => { const {Installer} = t.context; - t.plan(4); + t.plan(2); const installer = new Installer({ cwd: "/cwd/", ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); const callback = sinon.stub().callsFake(async () => { - t.is(t.context.lockStub.callCount, 1, "lock should have been called when the callback is invoked"); await Promise.resolve(); - t.is(t.context.unlockStub.callCount, 0, - "unlock should not be called when the callback did not fully resolve, yet"); }); await installer._synchronize("lock/name", callback); t.is(callback.callCount, 1, "callback should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called after _synchronize has resolved"); + t.pass("_synchronize resolved after callback completed"); }); test.serial("Installer: _synchronize should throw when locking fails", async (t) => { @@ -383,9 +355,8 @@ test.serial("Installer: _synchronize should throw when locking fails", async (t) ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(new Error("Locking error")); - - sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); + // Stub _synchronize directly to simulate withLock rejecting + sinon.stub(installer, "_synchronize").rejects(new Error("Locking error")); const callback = sinon.stub(); @@ -394,7 +365,6 @@ test.serial("Installer: _synchronize should throw when locking fails", async (t) }, {message: "Locking error"}); t.is(callback.callCount, 0, "callback should not be called"); - t.is(t.context.unlockStub.callCount, 0, "unlock should not be called"); }); test.serial("Installer: _synchronize should still unlock when callback throws an error", async (t) => { @@ -405,9 +375,6 @@ test.serial("Installer: _synchronize should still unlock when callback throws an ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); const callback = sinon.stub().throws(new Error("Callback throws error")); @@ -417,8 +384,6 @@ test.serial("Installer: _synchronize should still unlock when callback throws an }, {message: "Callback throws error"}); t.is(callback.callCount, 1, "callback should be called once"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); }); test.serial("Installer: _synchronize should still unlock when callback rejects with error", async (t) => { @@ -429,9 +394,6 @@ test.serial("Installer: _synchronize should still unlock when callback rejects w ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); const callback = sinon.stub().rejects(new Error("Callback rejects with error")); @@ -441,8 +403,6 @@ test.serial("Installer: _synchronize should still unlock when callback rejects w }, {message: "Callback rejects with error"}); t.is(callback.callCount, 1, "callback should be called once"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); }); test.serial("Installer: installPackage with new package", async (t) => { @@ -453,9 +413,6 @@ test.serial("Installer: installPackage with new package", async (t) => { ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const targetDir = path.join("my", "package", "dir"); const getTargetDirForPackageStub = sinon.stub(installer, "_getTargetDirForPackage") .returns(targetDir); @@ -494,8 +451,6 @@ test.serial("Installer: installPackage with new package", async (t) => { t.is(synchronizeSpy.callCount, 1, "_synchronize should be called once"); t.is(synchronizeSpy.getCall(0).args[0], "package-myPackage@1.2.3", "_synchronize should be called with the correct first argument"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); t.is(getStagingDirForPackageStub.callCount, 1, "_getStagingDirForPackage should be called once"); t.deepEqual(getStagingDirForPackageStub.getCall(0).args[0], { @@ -512,11 +467,9 @@ test.serial("Installer: installPackage with new package", async (t) => { t.is(extractPackageStub.callCount, 1, "_extractPackage should be called once"); - t.is(t.context.mkdirpStub.callCount, 2, "mkdirp should be called twice"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "framework", "locks"), + t.is(t.context.mkdirpStub.callCount, 1, "mkdirp should be called once"); + t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("my", "package"), "mkdirp should be called with the correct arguments on first call"); - t.is(t.context.mkdirpStub.getCall(1).args[0], path.join("my", "package"), - "mkdirp should be called with the correct arguments on second call"); t.is(t.context.renameStub.callCount, 1, "fs.rename should be called once"); t.is(t.context.renameStub.getCall(0).args[0], "staging-dir-path", @@ -533,9 +486,6 @@ test.serial("Installer: installPackage with already installed package", async (t ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const getTargetDirForPackageStub = sinon.stub(installer, "_getTargetDirForPackage") .returns("package-dir-path"); @@ -569,8 +519,6 @@ test.serial("Installer: installPackage with already installed package", async (t "_packageJsonExists should be called with the correct arguments on first call"); t.is(synchronizeSpy.callCount, 0, "_synchronize should never be called"); - t.is(t.context.lockStub.callCount, 0, "lock should never be called"); - t.is(t.context.unlockStub.callCount, 0, "unlock should never be called"); t.is(getStagingDirForPackageStub.callCount, 0, "_getStagingDirForPackage should never be called"); t.is(pathExistsStub.callCount, 0, "_pathExists should never be called"); t.is(t.context.rmrfStub.callCount, 0, "rmrf should never be called"); @@ -587,9 +535,6 @@ test.serial("Installer: installPackage with install already in progress", async ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const getTargetDirForPackageStub = sinon.stub(installer, "_getTargetDirForPackage") .returns("package-dir-path"); @@ -626,14 +571,10 @@ test.serial("Installer: installPackage with install already in progress", async t.is(synchronizeSpy.callCount, 1, "_synchronize should be called once"); t.is(synchronizeSpy.getCall(0).args[0], "package-myPackage@1.2.3", "_synchronize should be called with the correct first argument"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); t.is(t.context.rmrfStub.callCount, 0, "rmrf should never be called"); - t.is(t.context.mkdirpStub.callCount, 1, "mkdirp should be called once"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "framework", "locks"), - "mkdirp should be called with the correct arguments"); + t.is(t.context.mkdirpStub.callCount, 0, "mkdirp should never be called"); t.is(getStagingDirForPackageStub.callCount, 0, "_getStagingDirForPackage should never be called"); t.is(pathExistsStub.callCount, 0, "_pathExists should never be called"); @@ -649,9 +590,6 @@ test.serial("Installer: installPackage with new package and existing target and ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const targetDir = path.join("my", "package", "dir"); const getTargetDirForPackageStub = sinon.stub(installer, "_getTargetDirForPackage") .returns(targetDir); @@ -690,8 +628,6 @@ test.serial("Installer: installPackage with new package and existing target and t.is(synchronizeSpy.callCount, 1, "_synchronize should be called once"); t.is(synchronizeSpy.getCall(0).args[0], "package-myPackage@1.2.3", "_synchronize should be called with the correct first argument"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); t.is(getStagingDirForPackageStub.callCount, 1, "_getStagingDirForPackage should be called once"); t.deepEqual(getStagingDirForPackageStub.getCall(0).args[0], { @@ -713,11 +649,9 @@ test.serial("Installer: installPackage with new package and existing target and t.is(extractPackageStub.callCount, 1, "_extractPackage should be called once"); - t.is(t.context.mkdirpStub.callCount, 2, "mkdirp should be called twice"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "framework", "locks"), + t.is(t.context.mkdirpStub.callCount, 1, "mkdirp should be called once"); + t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("my", "package"), "mkdirp should be called with the correct arguments on first call"); - t.is(t.context.mkdirpStub.getCall(1).args[0], path.join("my", "package"), - "mkdirp should be called with the correct arguments on second call"); t.is(t.context.renameStub.callCount, 1, "fs.rename should be called once"); t.is(t.context.renameStub.getCall(0).args[0], "staging-dir-path", diff --git a/packages/project/test/lib/utils/dataDir.js b/packages/project/test/lib/utils/dataDir.js new file mode 100644 index 00000000000..7ccf6937ed6 --- /dev/null +++ b/packages/project/test/lib/utils/dataDir.js @@ -0,0 +1,80 @@ +import test from "ava"; +import path from "node:path"; +import os from "node:os"; +import sinon from "sinon"; +import esmock from "esmock"; +test.beforeEach(async (t) => { + t.context.originalUi5DataDirEnv = process.env.UI5_DATA_DIR; + delete process.env.UI5_DATA_DIR; + + t.context.configGetUi5DataDirStub = sinon.stub().returns(undefined); + t.context.ConfigurationStub = { + fromFile: sinon.stub().resolves({ + getUi5DataDir: t.context.configGetUi5DataDirStub + }) + }; + + const {resolveUi5DataDir} = await esmock("../../../lib/utils/dataDir.js", { + "../../../lib/config/Configuration.js": t.context.ConfigurationStub + }); + t.context.resolveUi5DataDir = resolveUi5DataDir; +}); + +test.afterEach.always((t) => { + if (typeof t.context.originalUi5DataDirEnv === "undefined") { + delete process.env.UI5_DATA_DIR; + } else { + process.env.UI5_DATA_DIR = t.context.originalUi5DataDirEnv; + } + sinon.restore(); +}); + +test.serial("resolveUi5DataDir: returns ~/.ui5 when nothing is configured", async (t) => { + const {resolveUi5DataDir} = t.context; + const result = await resolveUi5DataDir(); + t.is(result, path.join(os.homedir(), ".ui5")); +}); + +test.serial("resolveUi5DataDir: returns value from UI5_DATA_DIR env var (absolute)", async (t) => { + const {resolveUi5DataDir} = t.context; + process.env.UI5_DATA_DIR = "/custom/data/dir"; + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("/custom/data/dir")); + t.is(t.context.ConfigurationStub.fromFile.callCount, 0, "Configuration not read when env var is set"); +}); + +test.serial("resolveUi5DataDir: resolves relative UI5_DATA_DIR env var against cwd", async (t) => { + const {resolveUi5DataDir} = t.context; + process.env.UI5_DATA_DIR = "relative/data"; + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("relative/data")); +}); + +test.serial("resolveUi5DataDir: returns value from Configuration (absolute)", async (t) => { + const {resolveUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns("/config/data/dir"); + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("/config/data/dir")); +}); + +test.serial("resolveUi5DataDir: resolves relative Configuration value against cwd", async (t) => { + const {resolveUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns("my-data"); + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("my-data")); +}); + +test.serial("resolveUi5DataDir: env var takes precedence over Configuration", async (t) => { + const {resolveUi5DataDir} = t.context; + process.env.UI5_DATA_DIR = "/env/data"; + t.context.configGetUi5DataDirStub.returns("/config/data"); + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("/env/data")); +}); + +test.serial("resolveUi5DataDir: uses process.cwd() when cwd is not provided", async (t) => { + const {resolveUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns("relative/data"); + const result = await resolveUi5DataDir(); + t.is(result, path.resolve(process.cwd(), "relative/data")); +}); diff --git a/packages/project/test/lib/utils/lock.js b/packages/project/test/lib/utils/lock.js new file mode 100644 index 00000000000..c97e6313bd8 --- /dev/null +++ b/packages/project/test/lib/utils/lock.js @@ -0,0 +1,186 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import sinon from "sinon"; +import {promisify} from "node:util"; +import lockfileLib from "lockfile"; +import {getLockDir, LOCK_STALE_MS, CLEANUP_LOCK_NAME, acquireLock, hasActiveLocks} + from "../../../lib/utils/lock.js"; + +const lockfileUnlock = promisify(lockfileLib.unlock); + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "test", "tmp", "utils-lock"); + +test.beforeEach(async (t) => { + const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(testDir, {recursive: true}); + t.context.testDir = testDir; + t.context.lockPath = path.join(testDir, "test.lock"); +}); + +test.afterEach.always(async (t) => { + await lockfileUnlock(t.context.lockPath).catch(() => {}); +}); + +// ─── getLockDir ─────────────────────────────────────────────────────────────── + +test("getLockDir: appends locks subdirectory to the given ui5DataDir", (t) => { + t.is(getLockDir("/some/ui5/data"), path.join("/some/ui5/data", "locks")); +}); + +// ─── LOCK_STALE_MS ──────────────────────────────────────────────────────────── + +test("LOCK_STALE_MS: is exported and equals 60000", (t) => { + t.is(LOCK_STALE_MS, 60000); +}); + +test("CLEANUP_LOCK_NAME: is exported and equals cache-cleanup.lock", (t) => { + t.is(CLEANUP_LOCK_NAME, "cache-cleanup.lock"); +}); + +// ─── acquireLock ────────────────────────────────────────────────────────────── + +test.serial("acquireLock: creates lock dir and acquires lock, returns release fn", async (t) => { + const lockPath = t.context.lockPath; + + const release = await acquireLock(lockPath); + + // Lock file exists while lock is held + await t.notThrowsAsync(fs.access(lockPath), "lock file exists after acquire"); + + release(); + + // Lock file removed after release + await t.throwsAsync(fs.access(lockPath), {code: "ENOENT"}, "lock file removed after release"); +}); + +// ─── hasActiveLocks ─────────────────────────────────────────────────────────── + +test.serial("hasActiveLocks: returns false when locks directory does not exist", async (t) => { + const missingDir = path.join(t.context.testDir, "does-not-exist"); + t.false(await hasActiveLocks(missingDir), "no locks dir => no active locks"); +}); + +test.serial("hasActiveLocks: returns false when locks directory is empty", async (t) => { + t.false(await hasActiveLocks(t.context.testDir), "empty dir => no active locks"); +}); + +test.serial("hasActiveLocks: returns true when an active (non-stale) lock is present", async (t) => { + // Acquire a real lock so its filesystem timestamp is "now" + const release = await acquireLock(t.context.lockPath); + try { + t.true(await hasActiveLocks(t.context.testDir), "fresh lock detected as active"); + + // Active locks must not be deleted by the scan + await t.notThrowsAsync(fs.access(t.context.lockPath), "active lock preserved"); + } finally { + release(); + } +}); + +test.serial( + "hasActiveLocks: removes stale lock files left behind by crashed processes", + async (t) => { + const staleLockPathA = path.join(t.context.testDir, "crashed-a.lock"); + const staleLockPathB = path.join(t.context.testDir, "crashed-b.lock"); + + // Create two lock files on disk to simulate orphans from crashed processes. + await fs.writeFile(staleLockPathA, ""); + await fs.writeFile(staleLockPathB, ""); + + // Stub lockfile.check so both files are reported as stale (returns false). + // This avoids any reliance on filesystem timestamps or fake timers and + // keeps the test focused on the cleanup branch of hasActiveLocks. + const checkStub = sinon.stub(lockfileLib, "check").yields(null, false); + + try { + const result = await hasActiveLocks(t.context.testDir); + + t.false(result, "all locks are stale => returns false"); + t.is(checkStub.callCount, 2, "check called once per lock file"); + + await t.throwsAsync(fs.access(staleLockPathA), {code: "ENOENT"}, + "crashed-a.lock removed by hasActiveLocks"); + await t.throwsAsync(fs.access(staleLockPathB), {code: "ENOENT"}, + "crashed-b.lock removed by hasActiveLocks"); + } finally { + checkStub.restore(); + } + }, +); + +test.serial( + "hasActiveLocks: keeps active lock and removes stale neighbor in same scan", + async (t) => { + const staleLockPath = path.join(t.context.testDir, "stale.lock"); + const activeLockPath = path.join(t.context.testDir, "active.lock"); + + // Create both lock files on disk + await fs.writeFile(staleLockPath, ""); + await fs.writeFile(activeLockPath, ""); + + // Stub lockfile.check: stale.lock => false (stale), active.lock => true (live). + // Using explicit path matchers avoids any reliance on readdir order. + const checkStub = sinon.stub(lockfileLib, "check"); + checkStub.withArgs(staleLockPath, sinon.match.any).yields(null, false); + checkStub.withArgs(activeLockPath, sinon.match.any).yields(null, true); + + try { + const result = await hasActiveLocks(t.context.testDir); + + t.true(result, "scan returns true because one lock is active"); + + // Active lock preserved on disk + await t.notThrowsAsync(fs.access(activeLockPath), "active lock preserved"); + } finally { + checkStub.restore(); + } + }, +); + +test.serial("hasActiveLocks: honours include option (allowlist)", async (t) => { + const includedLockPath = path.join(t.context.testDir, "included.lock"); + const otherLockPath = path.join(t.context.testDir, "other.lock"); + + // Create lock files for both — only "included.lock" should be inspected. + await fs.writeFile(includedLockPath, ""); + await fs.writeFile(otherLockPath, ""); + + const checkStub = sinon.stub(lockfileLib, "check").yields(null, true); + + try { + const result = await hasActiveLocks(t.context.testDir, {include: "included.lock"}); + + t.true(result, "included lock detected as active"); + + // Only the included lock should have been passed to lockfile.check + t.is(checkStub.callCount, 1, "lockfile.check called exactly once"); + t.is(checkStub.firstCall.args[0], includedLockPath, + "lockfile.check called with the included lock path only"); + } finally { + checkStub.restore(); + } +}); + +test.serial("hasActiveLocks: honours exclude option (denylist)", async (t) => { + const excludedLockPath = path.join(t.context.testDir, "excluded.lock"); + const otherLockPath = path.join(t.context.testDir, "other.lock"); + + await fs.writeFile(excludedLockPath, ""); + await fs.writeFile(otherLockPath, ""); + + const checkStub = sinon.stub(lockfileLib, "check").yields(null, true); + + try { + const result = await hasActiveLocks(t.context.testDir, {exclude: "excluded.lock"}); + + t.true(result, "the non-excluded lock is detected"); + + // Only the non-excluded lock should have been passed to lockfile.check + t.is(checkStub.callCount, 1, "lockfile.check called exactly once"); + t.is(checkStub.firstCall.args[0], otherLockPath, + "lockfile.check called with the non-excluded lock path only"); + } finally { + checkStub.restore(); + } +});