diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index aff2f642c0c..99e090c1f9f 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -12,36 +12,30 @@ Please follow our [Contribution Guidelines](https://github.com/UI5/cli/blob/main ## UI5 Project ### `~/.ui5` Taking too Much Disk Space -UI5 CLI stores several kinds of data under your user's home directory in `~/.ui5/`: - -| Directory | Contents | Safe to delete? | -| ---- | ---- | ---- | -| `~/.ui5/framework/` | Downloaded UI5 framework dependencies (one copy per version) | Yes — re-downloaded on next invocation | -| `~/.ui5/buildCache/` | Build cache used by `ui5 build` and `ui5 serve` (see [Build Cache Control](./Builder.md#build-cache-control)) | Yes — rebuilt on next `ui5 build` / `ui5 serve` | -| `~/.ui5/server/` | Locally generated SSL certificate and private key for HTTPS / HTTP/2 mode | Yes — regenerated on next HTTPS server start; the new certificate must be re-trusted | - -::: warning -Only remove these directories when no UI5 CLI process and no `@ui5/*` API consumer is actively running. Deleting files that are in use can cause running builds or servers to fail or produce inconsistent results. -::: +There are possibly many versions of UI5 framework dependencies installed on your system, taking a large amount of disk space. #### Resolution -To free disk space, remove the relevant subdirectory. - -To only remove framework downloads: +Use the dedicated cache clean command, which safely removes all cached data: ```sh -rm -rf ~/.ui5/framework/ +ui5 cache clean ``` -To only remove the build cache: +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 -rm -rf ~/.ui5/buildCache/ +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 `ui5 config set ui5DataDir`, replace `~/.ui5/` with that path. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). +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 diff --git a/package-lock.json b/package-lock.json index 136313cc0a3..720cd3da131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18206,7 +18206,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.8.5", "update-notifier": "^7.3.1", - "yargs": "^18.0.0" + "yargs": "^18.0.0", + "yesno": "^0.4.0" }, "bin": { "ui5": "bin/ui5.cjs" diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js new file mode 100644 index 00000000000..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 bff07cc65f3..0c9eebef673 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -64,7 +64,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.8.5", "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/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..59d4dcdba36 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -1,29 +1,46 @@ +import path from "node:path"; +import {getRandomValues} from "node:crypto"; import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; +import {acquireLockSync, CLEANUP_LOCK_NAME, hasActiveLocks, getLockDir} from "../utils/lock.js"; /** * A rooted, directed graph representing a UI5 project, its dependencies and available extensions. - *

- * While it allows defining cyclic dependencies, both traversal functions will throw an error if they encounter cycles. + * + * When constructed with a ui5DataDir, the graph acquires a process-coordination + * lock during {@link enrichProjectGraph} to prevent concurrent ui5 cache clean + * operations. If a cache clean is already running, the lock acquisition waits for it to finish + * before proceeding. Call {@link destroy} to release the lock explicitly when the graph is no + * longer needed. Even without an explicit call, the lockfile package ensures the + * lock is released on process exit or unexpected termination. * * @public * @class * @alias @ui5/project/graph/ProjectGraph */ class ProjectGraph { + #lockRelease = null; + /** * @public * @param {object} parameters Parameters * @param {string} parameters.rootProjectName Root project name + * @param {string} [parameters.ui5DataDir] Explicit UI5 data directory to use for the build cache & locks. + * Overrides the UI5_DATA_DIR environment variable, the UI5 configuration file, + * and the default of ~/.ui5. */ - constructor({rootProjectName}) { + constructor({rootProjectName, ui5DataDir}) { if (!rootProjectName) { throw new Error(`Could not create ProjectGraph: Missing or empty parameter 'rootProjectName'`); } + if (!ui5DataDir) { + throw new Error(`Could not create ProjectGraph: Missing or empty parameter 'ui5DataDir'`); + } this._rootProjectName = rootProjectName; + this._ui5DataDir = ui5DataDir; this._projects = new Map(); // maps project name to instance (= nodes) this._adjList = new Map(); // maps project name to dependencies (= edges) @@ -688,6 +705,46 @@ class ProjectGraph { return this._taskRepository; } + /** + * Acquires a process-coordination lock scoped to this graph instance to prevent + * concurrent ui5 cache clean operations from running while framework + * packages are being downloaded or the build/serve lifecycle is active. + * + * If a cache clean is already in progress, polls until it finishes (up to 10 s) + * before acquiring the graph lock. The double-check after acquiring guards the + * narrow window between the poll and the lock acquisition. Throws if a cache + * clean is still active after that window. + * + * Called by projectGraphBuilder immediately after construction so the lock is + * in place before any framework downloads or build/serve work begins. + * The lock is released by {@link destroy}. + */ + async _preventCacheClean() { + const lockDir = getLockDir(this._ui5DataDir); + // Acquire our lock so any cache clean that starts now will detect us and abort. + // First acquire, then lock, so that the await of hasActiveLocks does not open a window for a race condition. + const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); + const lockPath = path.join(lockDir, `graph-${process.pid}-${lockId}.lock`); + this.#lockRelease = acquireLockSync(lockPath); + + // Poll until any in-progress cache clean releases its lock, or we time out. + const POLL_INTERVAL_MS = 200; + const TIMEOUT_MS = 10000; + const deadline = Date.now() + TIMEOUT_MS; + while (await hasActiveLocks(lockDir, {include: CLEANUP_LOCK_NAME})) { + if (Date.now() >= deadline) { + this.#lockRelease.release(); + this.#lockRelease = null; + + throw new Error( + "UI5 data directory is currently being cleaned. " + + "Please wait for the cache clean operation to finish and try again." + ); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + } + /** * Executes a build on the graph * @@ -716,10 +773,6 @@ class ProjectGraph { * Processes build results into a specific directory structure. * @param {module:@ui5/project/build/cache/Cache} [parameters.cache=Default] * Cache mode to use for building UI5 projects - * @param {string} [parameters.ui5DataDir] - * Explicit UI5 data directory to use for the build cache. Overrides the - * UI5_DATA_DIR environment variable, the UI5 configuration file, - * and the default of ~/.ui5. * @returns {Promise} Promise resolving to undefined once build has finished */ async build({ @@ -729,9 +782,9 @@ class ProjectGraph { selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], outputStyle = OutputStyleEnum.Default, - cache = Cache.Default, - ui5DataDir, + cache = Cache.Default }) { + this._preventCacheClean(); // Prevent concurrent cache clean operations while the graph is being built. this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { throw new Error( @@ -751,7 +804,7 @@ class ProjectGraph { includedTasks, excludedTasks, outputStyle, cache }, - ui5DataDir, + ui5DataDir: this._ui5DataDir, }); return await builder.buildToTarget({ destPath, cleanDest, @@ -765,9 +818,9 @@ class ProjectGraph { initialBuildIncludedDependencies = [], initialBuildExcludedDependencies = [], selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], - cache = Cache.Default, - ui5DataDir, + cache = Cache.Default }) { + this._preventCacheClean(); // Prevent concurrent cache clean operations while the graph is being served this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { throw new Error( @@ -788,7 +841,7 @@ class ProjectGraph { outputStyle: OutputStyleEnum.Default, cache }, - ui5DataDir, + ui5DataDir: this._ui5DataDir, }); const { default: BuildServer @@ -818,6 +871,24 @@ class ProjectGraph { return this._sealed; } + /** + * Releases the process-coordination lock held by this graph. + * Call this when the graph is no longer needed to unblock ui5 cache clean. + * + * If not called explicitly, the lockfile package's signal-exit + * handler releases the lock on normal process exit or signal termination. The lock file + * will also be ignored by ui5 cache clean once it ages past the staleness + * threshold (LOCK_STALE_MS). + * + * @public + */ + destroy() { + if (this.#lockRelease) { + this.#lockRelease(); + this.#lockRelease = null; + } + } + /** * Helper function to check and throw in case the project graph has been sealed. * Intended for use in any function that attempts to make changes to the graph. diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index 660cc78427e..e24efdc2d51 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}) { @@ -290,6 +289,8 @@ export default { * Promise resolving with the given graph instance to allow method chaining */ enrichProjectGraph: async function(projectGraph, options = {}) { + await projectGraph._preventCacheClean(); + const {workspace, snapshotCache} = options; const rootProject = projectGraph.getRoot(); const frameworkName = rootProject.getFrameworkName(); @@ -349,14 +350,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, { @@ -406,7 +400,8 @@ export default { } const frameworkGraph = new ProjectGraph({ - rootProjectName: `fake-root-of-${rootProject.getName()}-framework-dependency-graph` + rootProjectName: `fake-root-of-${rootProject.getName()}-framework-dependency-graph`, + ui5DataDir, }); const projectProcessor = new utils.ProjectProcessor({ diff --git a/packages/project/lib/graph/projectGraphBuilder.js b/packages/project/lib/graph/projectGraphBuilder.js index 1376d3d224f..8db1f60e400 100644 --- a/packages/project/lib/graph/projectGraphBuilder.js +++ b/packages/project/lib/graph/projectGraphBuilder.js @@ -3,6 +3,7 @@ import Module from "./Module.js"; import ProjectGraph from "./ProjectGraph.js"; import ShimCollection from "./ShimCollection.js"; import {getLogger} from "@ui5/logger"; +import {resolveUi5DataDir} from "../utils/dataDir.js"; const log = getLogger("graph:projectGraphBuilder"); function _handleExtensions(graph, shimCollection, extensions) { @@ -134,7 +135,8 @@ async function projectGraphBuilder(nodeProvider, workspace) { const projectGraph = new ProjectGraph({ - rootProjectName: rootProjectName + rootProjectName: rootProjectName, + ui5DataDir: await resolveUi5DataDir(), }); projectGraph.addProject(rootProject); 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..7d4f3ed405f --- /dev/null +++ b/packages/project/lib/ui5Framework/cache.js @@ -0,0 +1,139 @@ +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 abort rather than writing into a + * directory that is being deleted. + * + * The lock directory (~/.ui5/locks/) is outside + * ~/.ui5/framework/ and is not affected by the deletion. + * + * @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..77d50508e1c --- /dev/null +++ b/packages/project/lib/utils/lock.js @@ -0,0 +1,175 @@ +import path from "node:path"; +import {readdir, mkdir} from "node:fs/promises"; +import {mkdirSync, utimesSync, existsSync} from "node:fs"; +import {promisify} from "node:util"; +import lockfile from "lockfile"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("lock"); + +/** + * 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; + +/** + * Interval at which long-lived graph locks refresh their mtime. + * Must be less than LOCK_STALE_MS to keep the lock always within the freshness window. + * Must not be even of LOCK_STALE_MS to avoid a race condition where a lock is refreshed + * at the same time another process checks for staleness. + */ +export const LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6; + +/** + * 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; +} + +/** + * Creates the release function for a lock that has already been acquired. + * Starts the mtime-refresh interval and returns an idempotent release function + * that stops the interval and calls lockfile.unlockSync. + * + * @param {string} lockPath Absolute path to the lock file + * @returns {Function} Synchronous release() function + */ +function resolveReleaseLockFn(lockPath) { + const interval = setInterval(() => { + if (!existsSync(lockPath)) { + clearInterval(interval); + return; + } + const now = new Date(); + utimesSync(lockPath, now, now); + }, LOCK_REFRESH_INTERVAL_MS); + interval.unref(); + + let released = false; + return function release() { + if (released) return; + released = true; + clearInterval(interval); + lockfile.unlockSync(lockPath); + log.verbose(`Released ${lockPath} lock`); + }; +} + +/** + * Synchronously acquire a lockfile and return a release function. + * + * Use this for operations that run synchronously (e.g. graph construction). + * For operations that need to wait for a contended lock without blocking the + * event loop, use {@link acquireLock} instead. + * + * The returned release() function must be called to release the lock on graceful + * shutdown. It also stops the mtime-refresh interval. 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.retries] Number of synchronous retries on contention. + * Each retry blocks the event loop — only use with a unique lock path where + * contention is impossible (e.g. a path containing a per-process random suffix). + * @returns {Function} Synchronous release() function + */ +export function acquireLockSync(lockPath, {retries} = {}) { + mkdirSync(path.dirname(lockPath), {recursive: true}); + log.verbose(`Locking ${lockPath}`); + lockfile.lockSync(lockPath, {stale: LOCK_STALE_MS, retries}); + return resolveReleaseLockFn(lockPath); +} + +/** + * Asynchronously acquire a lockfile and return a release function. + * + * Use this when the lock may be contended and waiting must not block the event loop + * (e.g. the framework package installer, where multiple packages are installed concurrently). + * + * The returned release() function must be called to release the lock on graceful + * shutdown. It also stops the mtime-refresh interval. 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} = {}) { + log.verbose(`Locking ${lockPath}`); + await mkdir(path.dirname(lockPath), {recursive: true}); + await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS, wait, retries}); + return resolveReleaseLockFn(lockPath); +} diff --git a/packages/project/package.json b/packages/project/package.json index c55b2c9f866..5a843eb5e09 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..e4c13a7f72a 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -1077,13 +1077,18 @@ class FixtureTester { } async serveProject({graphConfig = {}, config = {}, expectBuildErrors = false} = {}) { + // Point resolveUi5DataDir() to the fixture-isolated data dir so the graph + // constructor and the build cache use the same isolated path. + const olDataDir = process.env.UI5_DATA_DIR; + process.env.UI5_DATA_DIR = this.ui5DataDir; const graph = this.graph = await graphFromPackageDependencies({ ...graphConfig, cwd: this.fixturePath, }); + process.env.UI5_DATA_DIR = olDataDir; // Execute the build - this.buildServer = await graph.serve({...config, ui5DataDir: this.ui5DataDir}); + this.buildServer = await graph.serve({...config}); this.buildServer.on("error", (err) => { if (!expectBuildErrors) { this._t.fail(`Build server error: ${err.message}`); diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index c754b7213f1..d795343d5cf 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -2718,13 +2718,19 @@ class FixtureTester { await this._initialize(); this._sinon.resetHistory(); + // Point resolveUi5DataDir() to the fixture-isolated data dir so the graph + // constructor and the build cache use the same isolated path. + const oldUi5DataDir = process.env.UI5_DATA_DIR; + process.env.UI5_DATA_DIR = this.ui5DataDir; const graph = await graphFromPackageDependencies({ ...graphConfig, cwd: this.fixturePath, }); + process.env.UI5_DATA_DIR = oldUi5DataDir; + // Execute the build - await graph.build({...config, ui5DataDir: this.ui5DataDir}); + await graph.build({...config}); // Apply assertions if provided if (assertions) { 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/ProjectGraph.js b/packages/project/test/lib/graph/ProjectGraph.js index 9f546a47685..a7a9b97e402 100644 --- a/packages/project/test/lib/graph/ProjectGraph.js +++ b/packages/project/test/lib/graph/ProjectGraph.js @@ -6,6 +6,9 @@ import Specification from "../../../lib/specifications/Specification.js"; const __dirname = import.meta.dirname; +// Dummy data directory passed to all ProjectGraph instances in unit tests. +const TEST_UI5_DATA_DIR = "/test/tmp/ui5/data"; + const applicationAPath = path.join(__dirname, "..", "..", "fixtures", "application.a"); async function createProject(name) { @@ -77,7 +80,13 @@ test.beforeEach(async (t) => { t.context.ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { "@ui5/logger": { getLogger: sinon.stub().withArgs("graph:ProjectGraph").returns(t.context.log) - } + }, + "../../../lib/utils/lock.js": { + acquireLockSync: sinon.stub().returns(() => {}), + getLockDir: sinon.stub().returns("/test/locks"), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + }, }); }); @@ -90,7 +99,8 @@ test("Instantiate a basic project graph", (t) => { const {ProjectGraph} = t.context; t.notThrows(() => { new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); }, "Should not throw"); }); @@ -107,7 +117,8 @@ test("Instantiate a basic project with missing parameter rootProjectName", (t) = test("getRoot", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "application.a" + rootProjectName: "application.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project = await createProject("application.a"); graph.addProject(project); @@ -118,7 +129,8 @@ test("getRoot", async (t) => { test("getRoot: Root not added to graph", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "application.a" + rootProjectName: "application.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = t.throws(() => { @@ -132,7 +144,8 @@ test("getRoot: Root not added to graph", (t) => { test("add-/getProject", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project = await createProject("application.a"); graph.addProject(project); @@ -143,7 +156,8 @@ test("add-/getProject", async (t) => { test("addProject: Add duplicate", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project1 = await createProject("application.a"); graph.addProject(project1); @@ -164,7 +178,8 @@ test("addProject: Add duplicate", async (t) => { test("addProject: Add project with integer-like name", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project = await createProject("1337"); @@ -179,7 +194,8 @@ test("addProject: Add project with integer-like name", async (t) => { test("getProject: Project is not in graph", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const res = graph.getProject("application.a"); t.is(res, undefined, "Should return undefined"); @@ -188,7 +204,8 @@ test("getProject: Project is not in graph", (t) => { test("getProjects", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project1 = await createProject("application.a"); graph.addProject(project1); @@ -205,7 +222,8 @@ test("getProjects", async (t) => { test("getProjectNames", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project1 = await createProject("application.a"); graph.addProject(project1); @@ -222,7 +240,8 @@ test("getProjectNames", async (t) => { test("getSize", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project1 = await createProject("application.a"); graph.addProject(project1); @@ -240,7 +259,8 @@ test("getSize", async (t) => { test("add-/getExtension", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const extension = await createExtension("extension.a"); graph.addExtension(extension); @@ -251,7 +271,8 @@ test("add-/getExtension", async (t) => { test("addExtension: Add duplicate", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const extension1 = await createExtension("extension.a"); graph.addExtension(extension1); @@ -272,7 +293,8 @@ test("addExtension: Add duplicate", async (t) => { test("addExtension: Add extension with integer-like name", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const extension = await createExtension("1337"); @@ -287,7 +309,8 @@ test("addExtension: Add extension with integer-like name", async (t) => { test("getExtension: Project is not in graph", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const res = graph.getExtension("extension.a"); t.is(res, undefined, "Should return undefined"); @@ -296,7 +319,8 @@ test("getExtension: Project is not in graph", (t) => { test("getExtensions", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const extension1 = await createExtension("extension.a"); graph.addExtension(extension1); @@ -312,7 +336,8 @@ test("getExtensions", async (t) => { test("declareDependency / getDependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -343,7 +368,8 @@ test("declareDependency / getDependencies", async (t) => { test("getTransitiveDependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -368,7 +394,8 @@ test("getTransitiveDependencies", async (t) => { test("getTransitiveDependencies: Unknown project", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = t.throws(() => { @@ -382,7 +409,8 @@ test("getTransitiveDependencies: Unknown project", (t) => { test("declareDependency: Unknown source", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.b")); @@ -398,7 +426,8 @@ test("declareDependency: Unknown source", async (t) => { test("declareDependency: Unknown target", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -414,7 +443,8 @@ test("declareDependency: Unknown target", async (t) => { test("declareDependency: Same target as source", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -431,7 +461,8 @@ test("declareDependency: Same target as source", async (t) => { test("declareDependency: Already declared", async (t) => { const {ProjectGraph, log} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -448,7 +479,8 @@ test("declareDependency: Already declared", async (t) => { test("declareDependency: Already declared as optional", async (t) => { const {ProjectGraph, log} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -468,7 +500,8 @@ test("declareDependency: Already declared as optional", async (t) => { test("declareDependency: Already declared as non-optional", async (t) => { const {ProjectGraph, log} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -486,7 +519,8 @@ test("declareDependency: Already declared as non-optional", async (t) => { test("declareDependency: Already declared as optional, now non-optional", async (t) => { const {ProjectGraph, log} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -503,7 +537,8 @@ test("declareDependency: Already declared as optional, now non-optional", async test("getDependencies: Project without dependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -515,7 +550,8 @@ test("getDependencies: Project without dependencies", async (t) => { test("getDependencies: Unknown project", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = t.throws(() => { @@ -529,7 +565,8 @@ test("getDependencies: Unknown project", (t) => { test("resolveOptionalDependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -564,7 +601,8 @@ test("resolveOptionalDependencies", async (t) => { test("resolveOptionalDependencies: Optional dependency has not been resolved", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -615,7 +653,8 @@ test("resolveOptionalDependencies: Optional dependency has not been resolved", a test("resolveOptionalDependencies: Dependency of optional dependency has not been resolved", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -642,7 +681,8 @@ test("resolveOptionalDependencies: Dependency of optional dependency has not bee test("resolveOptionalDependencies: Cyclic optional dependency is not resolved", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -671,7 +711,8 @@ test("resolveOptionalDependencies: Cyclic optional dependency is not resolved", test("resolveOptionalDependencies: Resolves transitive optional dependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -706,7 +747,8 @@ test("resolveOptionalDependencies: Resolves transitive optional dependencies", a test("traverseBreadthFirst: Async", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -736,7 +778,8 @@ test("traverseBreadthFirst: Async", async (t) => { test("traverseBreadthFirst: Sync", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -759,7 +802,8 @@ test("traverseBreadthFirst: Sync", async (t) => { test("traverseBreadthFirst: No project visited twice", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -779,7 +823,8 @@ test("traverseBreadthFirst: No project visited twice", async (t) => { test("traverseBreadthFirst: Detect cycle", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -796,7 +841,8 @@ test("traverseBreadthFirst: Detect cycle", async (t) => { test("traverseBreadthFirst: No cycle when visited breadth first", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -817,7 +863,8 @@ test("traverseBreadthFirst: No cycle when visited breadth first", async (t) => { test("traverseBreadthFirst: Can't find start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = await t.throwsAsync(graph.traverseBreadthFirst(() => {})); @@ -829,7 +876,8 @@ test("traverseBreadthFirst: Can't find start node", async (t) => { test("traverseBreadthFirst: Custom start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -854,7 +902,8 @@ test("traverseBreadthFirst: Custom start node", async (t) => { test("traverseBreadthFirst: dependencies parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -890,7 +939,8 @@ test("traverseBreadthFirst: dependencies parameter", async (t) => { test("traverseBreadthFirst: Dependency declaration order is followed", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -909,7 +959,8 @@ test("traverseBreadthFirst: Dependency declaration order is followed", async (t) ]); const graph2 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph2.addProject(await createProject("library.a")); graph2.addProject(await createProject("library.b")); @@ -931,7 +982,8 @@ test("traverseBreadthFirst: Dependency declaration order is followed", async (t) test("traverseDepthFirst: Async", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -961,7 +1013,8 @@ test("traverseDepthFirst: Async", async (t) => { test("traverseDepthFirst: Sync", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -984,7 +1037,8 @@ test("traverseDepthFirst: Sync", async (t) => { test("traverseDepthFirst: No project visited twice", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1004,7 +1058,8 @@ test("traverseDepthFirst: No project visited twice", async (t) => { test("traverseDepthFirst: Detect cycle", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1021,7 +1076,8 @@ test("traverseDepthFirst: Detect cycle", async (t) => { test("traverseDepthFirst: Cycle which does not occur in BFS", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1041,7 +1097,8 @@ test("traverseDepthFirst: Cycle which does not occur in BFS", async (t) => { test("traverseDepthFirst: Can't find start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = await t.throwsAsync(graph.traverseDepthFirst(() => {})); @@ -1053,7 +1110,8 @@ test("traverseDepthFirst: Can't find start node", async (t) => { test("traverseDepthFirst: Custom start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1078,7 +1136,8 @@ test("traverseDepthFirst: Custom start node", async (t) => { test("traverseDepthFirst: dependencies parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1114,7 +1173,8 @@ test("traverseDepthFirst: dependencies parameter", async (t) => { test("traverseDepthFirst: Dependency declaration order is followed", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -1133,7 +1193,8 @@ test("traverseDepthFirst: Dependency declaration order is followed", async (t) = ]); const graph2 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph2.addProject(await createProject("library.a")); graph2.addProject(await createProject("library.b")); @@ -1155,7 +1216,8 @@ test("traverseDepthFirst: Dependency declaration order is followed", async (t) = test("traverseDependenciesDepthFirst: Basic traversal without including start module", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1178,7 +1240,8 @@ test("traverseDependenciesDepthFirst: Basic traversal without including start mo test("traverseDependenciesDepthFirst: Basic traversal including start module", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1202,7 +1265,8 @@ test("traverseDependenciesDepthFirst: Basic traversal including start module", a test("traverseDependenciesDepthFirst: Using boolean as first parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1226,7 +1290,8 @@ test("traverseDependenciesDepthFirst: Using boolean as first parameter", async ( test("traverseDependenciesDepthFirst: No dependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -1241,7 +1306,8 @@ test("traverseDependenciesDepthFirst: No dependencies", async (t) => { test("traverseDependenciesDepthFirst: Diamond dependency structure", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1268,7 +1334,8 @@ test("traverseDependenciesDepthFirst: Diamond dependency structure", async (t) = test("traverseDependenciesDepthFirst: Complex dependency chain", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1297,7 +1364,8 @@ test("traverseDependenciesDepthFirst: Complex dependency chain", async (t) => { test("traverseDependenciesDepthFirst: No project visited twice", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1323,7 +1391,8 @@ test("traverseDependenciesDepthFirst: No project visited twice", async (t) => { test("traverseDependenciesDepthFirst: Can't find start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -1341,7 +1410,8 @@ test("traverseDependenciesDepthFirst: Can't find start node", async (t) => { test("traverseDependenciesDepthFirst: dependencies parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1377,7 +1447,8 @@ test("traverseDependenciesDepthFirst: dependencies parameter", async (t) => { test("traverseDependenciesDepthFirst: Detect cycle", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1399,7 +1470,8 @@ test("traverseDependenciesDepthFirst: Detect cycle", async (t) => { test("traverseDependenciesDepthFirst: Dependency declaration order is followed", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -1422,7 +1494,8 @@ test("traverseDependenciesDepthFirst: Dependency declaration order is followed", ], "First graph should visit in declaration order"); const graph2 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph2.addProject(await createProject("library.a")); graph2.addProject(await createProject("library.b")); @@ -1448,7 +1521,8 @@ test("traverseDependenciesDepthFirst: Dependency declaration order is followed", test("traverseDependents: Basic traversal without including start module", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1471,7 +1545,8 @@ test("traverseDependents: Basic traversal without including start module", async test("traverseDependents: Basic traversal including start module", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1495,7 +1570,8 @@ test("traverseDependents: Basic traversal including start module", async (t) => test("traverseDependents: Using boolean as first parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.c" + rootProjectName: "library.c", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1519,7 +1595,8 @@ test("traverseDependents: Using boolean as first parameter", async (t) => { test("traverseDependents: No dependents", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1537,7 +1614,8 @@ test("traverseDependents: No dependents", async (t) => { test("traverseDependents: Multiple dependents", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1563,7 +1641,8 @@ test("traverseDependents: Multiple dependents", async (t) => { test("traverseDependents: Complex chain", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1592,7 +1671,8 @@ test("traverseDependents: Complex chain", async (t) => { test("traverseDependents: No project visited twice", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1620,7 +1700,8 @@ test("traverseDependents: No project visited twice", async (t) => { test("traverseDependents: Can't find start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -1639,7 +1720,8 @@ test("traverseDependents: Can't find start node", async (t) => { test("traverseDependents: dependents parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1677,7 +1759,8 @@ test("traverseDependents: dependents parameter", async (t) => { test("traverseDependents: Detect cycle", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1699,10 +1782,12 @@ test("traverseDependents: Detect cycle", async (t) => { test("join", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "theme.a" + rootProjectName: "theme.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -1764,10 +1849,12 @@ test("join", async (t) => { test("join: Preserves hasUnresolvedOptionalDependencies flag", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "theme.a" + rootProjectName: "theme.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -1784,10 +1871,12 @@ test("join: Preserves hasUnresolvedOptionalDependencies flag", async (t) => { test("join: Seals incoming graph", (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "theme.a" + rootProjectName: "theme.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); @@ -1800,10 +1889,12 @@ test("join: Seals incoming graph", (t) => { test("join: Incoming graph already sealed", (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "theme.a" + rootProjectName: "theme.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph2.seal(); @@ -1816,10 +1907,12 @@ test("join: Incoming graph already sealed", (t) => { test("join: Unexpected project intersection", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "😹" + rootProjectName: "😹", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "😼" + rootProjectName: "😼", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph2.addProject(await createProject("library.a")); @@ -1837,10 +1930,12 @@ test("join: Unexpected project intersection", async (t) => { test("join: Unexpected extension intersection", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "😹" + rootProjectName: "😹", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "😼" + rootProjectName: "😼", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addExtension(await createExtension("extension.a")); graph2.addExtension(await createExtension("extension.a")); @@ -1859,7 +1954,8 @@ test("join: Unexpected extension intersection", async (t) => { test("Seal/isSealed", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1907,7 +2003,8 @@ test("Seal/isSealed", async (t) => { const graph2 = new ProjectGraph({ - rootProjectName: "library.x" + rootProjectName: "library.x", + ui5DataDir: TEST_UI5_DATA_DIR, }); t.throws(() => { graph.join(graph2); diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index 93096d50109..636845cd910 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -62,7 +62,8 @@ test.beforeEach(async (t) => { }, "lockfile": { lock: sinon.stub().yieldsAsync(), - unlock: sinon.stub().yieldsAsync() + unlock: sinon.stub().yieldsAsync(), + unlockSync: sinon.stub(), } }); @@ -135,7 +136,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,9 +762,9 @@ defineErrorTest( frameworkName: "OpenUI5", failMetadata: true, failExtract: true, - 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` + // Both manifest() and extract() fail concurrently. Which error surfaces first is + // non-deterministic across Node versions and platforms — accept either variant. + expectedErrorMessage: /^Resolution of framework libraries failed with errors:\n\s+1\. Failed to resolve library sap\.ui\.lib1: (Failed to read manifest of|Failed to extract package) @openui5\/sap\.ui\.lib1/ }); test.serial("ui5Framework helper should not fail when no framework configuration is given", async (t) => { diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.js b/packages/project/test/lib/graph/helpers/ui5Framework.js index b134ac187ac..4147c22a51d 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("/test/ui5/data"); 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; }); @@ -131,7 +127,7 @@ test.serial("enrichProjectGraph", async (t) => { snapshotCache: undefined, cwd: dependencyTree.path, version: dependencyTree.configuration.framework.version, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); @@ -336,7 +332,7 @@ test.serial("enrichProjectGraph: With versionOverride", async (t) => { t.is(Sapui5ResolverResolveVersionStub.callCount, 1); t.deepEqual(Sapui5ResolverResolveVersionStub.getCall(0).args, ["1.99", { cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", }]); t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); @@ -344,7 +340,7 @@ test.serial("enrichProjectGraph: With versionOverride", async (t) => { snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -398,7 +394,7 @@ test.serial("enrichProjectGraph: With versionOverride containing snapshot versio t.is(Sapui5MavenSnapshotResolverResolveVersionStub.callCount, 1); t.deepEqual(Sapui5MavenSnapshotResolverResolveVersionStub.getCall(0).args, ["1.99-SNAPSHOT", { cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", }]); t.is(Sapui5MavenSnapshotResolverStub.callCount, 1, @@ -407,7 +403,7 @@ test.serial("enrichProjectGraph: With versionOverride containing snapshot versio snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9-SNAPSHOT", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -461,7 +457,7 @@ test.serial("enrichProjectGraph: With versionOverride containing latest-snapshot t.is(Sapui5MavenSnapshotResolverResolveVersionStub.callCount, 1); t.deepEqual(Sapui5MavenSnapshotResolverResolveVersionStub.getCall(0).args, ["latest-snapshot", { cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", }]); t.is(Sapui5MavenSnapshotResolverStub.callCount, 1, @@ -470,7 +466,7 @@ test.serial("enrichProjectGraph: With versionOverride containing latest-snapshot snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9-SNAPSHOT", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -630,7 +626,7 @@ test.serial("enrichProjectGraph should resolve framework project with version an snapshotCache: undefined, cwd: dependencyTree.path, version: "1.2.3", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -726,7 +722,7 @@ test.serial("enrichProjectGraph should resolve framework project " + t.is(Sapui5ResolverResolveVersionStub.callCount, 1); t.deepEqual(Sapui5ResolverResolveVersionStub.getCall(0).args, ["3.4.5", { cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", }]); t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); @@ -735,7 +731,7 @@ test.serial("enrichProjectGraph should resolve framework project " + snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -1000,7 +996,7 @@ test.serial("enrichProjectGraph should use framework library metadata from works snapshotCache: undefined, cwd: dependencyTree.path, version: "1.111.1", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: workspaceFrameworkLibraryMetadata }], "Sapui5Resolver#constructor should be called with expected args"); t.is(Sapui5ResolverStub.getCall(0).args[0].providedLibraryMetadata, workspaceFrameworkLibraryMetadata); @@ -1058,7 +1054,7 @@ test.serial("enrichProjectGraph should allow omitting framework version in case t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ snapshotCache: undefined, cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", version: undefined, providedLibraryMetadata: workspaceFrameworkLibraryMetadata }], "Sapui5Resolver#constructor should be called with expected args"); @@ -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..b70374ef351 --- /dev/null +++ b/packages/project/test/lib/utils/lock.js @@ -0,0 +1,280 @@ +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, + LOCK_REFRESH_INTERVAL_MS, + CLEANUP_LOCK_NAME, + acquireLockSync, + 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"); +}); + +// ─── acquireLockSync ────────────────────────────────────────────────────────── + +test("LOCK_REFRESH_INTERVAL_MS: is exported and equals LOCK_STALE_MS * 0.6", (t) => { + t.is(LOCK_REFRESH_INTERVAL_MS, LOCK_STALE_MS * 0.6); +}); + +test.serial("acquireLockSync: returns a release function", (t) => { + const release = acquireLockSync(t.context.lockPath); + try { + t.is(typeof release, "function", "acquireLockSync returns a function"); + } finally { + release(); + } +}); + +test.serial("acquireLockSync: release() removes the lock file", async (t) => { + const release = acquireLockSync(t.context.lockPath); + + await t.notThrowsAsync(fs.access(t.context.lockPath), "lock file exists after acquire"); + + release(); + + await t.throwsAsync(fs.access(t.context.lockPath), {code: "ENOENT"}, "lock file removed after release"); +}); + +test.serial("acquireLockSync: release() is idempotent", (t) => { + const release = acquireLockSync(t.context.lockPath); + release(); + t.notThrows(() => release(), "second release() call does not throw"); +}); + +test.serial("acquireLockSync: release() stops the refresh interval", async (t) => { + const release = acquireLockSync(t.context.lockPath); + const statBefore = await fs.stat(t.context.lockPath); + + // Release immediately — interval must stop + release(); + + // Wait two interval periods and confirm mtime did not advance + await new Promise((resolve) => setTimeout(resolve, LOCK_REFRESH_INTERVAL_MS * 2 + 200)); + + // Lock file is gone after release, so we re-check by confirming ENOENT (interval can't update a deleted file) + await t.throwsAsync(fs.access(t.context.lockPath), {code: "ENOENT"}, + "lock file gone — interval has nothing to refresh"); + t.truthy(statBefore, "stat was readable before release"); +}); + +test.serial("acquireLockSync: refresh interval keeps mtime fresh while lock is held", async (t) => { + const release = acquireLockSync(t.context.lockPath); + try { + const statBefore = await fs.stat(t.context.lockPath); + // Wait longer than one interval tick + await new Promise((resolve) => setTimeout(resolve, LOCK_REFRESH_INTERVAL_MS + 200)); + const statAfter = await fs.stat(t.context.lockPath); + t.true(statAfter.mtimeMs >= statBefore.mtimeMs, "mtime updated by refresh interval while lock is held"); + } finally { + release(); + } +}); + +// ─── acquireLock ───────────────────────────────────────────────────────────── + +test.serial("acquireLock: returns a release function", async (t) => { + const release = await acquireLock(t.context.lockPath); + try { + t.is(typeof release, "function", "acquireLock resolves with a function"); + } finally { + release(); + } +}); + +test.serial("acquireLock: release() removes the lock file", async (t) => { + const release = await acquireLock(t.context.lockPath); + + await t.notThrowsAsync(fs.access(t.context.lockPath), "lock file exists after acquire"); + + release(); + + await t.throwsAsync(fs.access(t.context.lockPath), {code: "ENOENT"}, "lock file removed after release"); +}); + +test.serial("acquireLock: release() is idempotent", async (t) => { + const release = await acquireLock(t.context.lockPath); + release(); + t.notThrows(() => release(), "second release() call does not throw"); +}); + +test.serial("acquireLock: waits for a contended lock without blocking", async (t) => { + // Acquire the lock from the outside first + const firstRelease = await acquireLock(t.context.lockPath); + try { + // Second acquirer should wait and eventually succeed once the first releases + const acquirePromise = acquireLock(t.context.lockPath, {wait: 5000, retries: 10}); + // Release the first lock after a short delay + setTimeout(() => firstRelease(), 50); + const secondRelease = await acquirePromise; + t.pass("second acquireLock resolved after first was released"); + secondRelease(); + } finally { + firstRelease(); // no-op if already released + } +}); + +// ─── 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 acquireLockSync(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(); + } +});