diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index aff2f642c0c..f8fc8fcb8d9 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -26,22 +26,27 @@ Only remove these directories when no UI5 CLI process and no `@ui5/*` API consum #### 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 displays the cache location, the amount of data that gets removed, and asks for confirmation before proceeding. To skip the confirmation prompt (for example in CI environments), use the `--yes` flag: ```sh -rm -rf ~/.ui5/buildCache/ +ui5 cache clean --yes ``` +The command removes the following cached data: +- **UI5 framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) +- **Build cache (DB)** — build data (`~/.ui5/buildCache/`) +- **Orphaned framework data** — incomplete framework directories left over from previously interrupted cleanup operations (`~/.ui5/.framework_to_delete_*/`) + +Any missing framework dependencies are downloaded 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 `ui5 config set ui5DataDir`, the `ui5 cache clean` command automatically cleans up that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). ::: ## 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..787839cfde5 --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -0,0 +1,324 @@ +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 {acquireLock, CLEANUP_LOCK_NAME, 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 confirmation") + .example("$0 cache clean --yes", + "Remove all cached UI5 data without confirmation (e.g. in CI scenarios)") + .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 'ui5 config set ui5DataDir' configuration option (see 'ui5 config --help').\n\n" + + "The following cache types are removed:\n" + + " UI5 framework packages: Downloaded UI5 library files " + + "(~/.ui5/framework/)\n" + + " Build cache (DB): Build data " + + "(~/.ui5/buildCache/)\n" + + " Orphaned framework data: Incomplete directories from previously interrupted cleanups\n" + + " (~/.ui5/.framework_to_delete_*/)" + ); + }, + 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, + * and any orphaned staging directories from previously interrupted clean operations. + * + * @param {object} data + * @param {object|null} data.frameworkInfo + * @param {object|null} data.buildInfo + * @param {string|null} data.frameworkAbsPath + * @param {string|null} data.buildAbsPath + * @param {number} data.buildPreSize + * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.orphanedInfo + */ +async function displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfo, +}) { + // 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` + ); + } + if (orphanedInfo && orphanedInfo.length > 0) { + process.stderr.write( + ` ${chalk.yellow("•")} ${chalk.bold("Orphaned framework data")}` + + ` (incomplete previous clean — ` + + `${orphanedInfo.length} director${orphanedInfo.length === 1 ? "y" : "ies"})\n` + ); + for (const orphan of orphanedInfo) { + const detail = formatFrameworkStats(orphan.libraryCount, orphan.versionCount); + process.stderr.write(` ${chalk.dim(orphan.absPath)} (${detail})\n`); + } + } + process.stderr.write("\n"); +} + +/** + * Display the result of the cache cleanup operation, + * including which caches were removed and their details, + * and any orphaned staging directories that were also cleaned up. + * + * @param {object} data + * @param {object|null} data.frameworkResult + * @param {object|null} data.buildResult + * @param {string|null} data.frameworkAbsPath + * @param {string|null} data.buildAbsPath + * @param {number} data.buildPreSize + * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.orphanedInfoWithAbsPaths + */ +async function displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfoWithAbsPaths, +}) { + process.stderr.write("\n"); + if (frameworkResult && frameworkAbsPath) { + const detail = formatFrameworkStats( + frameworkResult.libraryCount, + frameworkResult.versionCount, + ); + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + + ` (${frameworkAbsPath} · ${detail})\n`, + ); + } + if (orphanedInfoWithAbsPaths && orphanedInfoWithAbsPaths.length > 0) { + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold("Orphaned framework data")}` + + ` (${orphanedInfoWithAbsPaths.length}` + + ` director${orphanedInfoWithAbsPaths.length === 1 ? "y" : "ies"})\n` + ); + for (const orphan of orphanedInfoWithAbsPaths) { + const detail = formatFrameworkStats(orphan.libraryCount, orphan.versionCount); + process.stderr.write(` ${chalk.dim(orphan.absPath)} (${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 (orphanedInfoWithAbsPaths && orphanedInfoWithAbsPaths.length > 0) { + cleaned.push("Orphaned framework data"); + } + 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) { + const ui5DataDir = await resolveUi5DataDir(); + const lockDir = getLockDir(ui5DataDir); + const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); + + // Acquire first, then check — ensures concurrent framework operations will see + // the cleanup lock and abort before the actual cleanup. + const releaseCleanupLock = await acquireLock(lockPath); + + try { + // Abort early if a lock is active — before prompting the user. + if (await hasActiveLocks(ui5DataDir, {exclude: CLEANUP_LOCK_NAME})) { + 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; + } + + process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + + const [frameworkInfo, buildInfo, orphanedInfo] = await Promise.all([ + frameworkCache.getCacheInfo(ui5DataDir), + CacheManager.getCacheInfo(ui5DataDir), + frameworkCache.getOrphanedInfo(ui5DataDir), + ]); + + if (!frameworkInfo && !buildInfo && orphanedInfo.length === 0) { + process.stderr.write("Nothing to clean\n"); + return; + } + + // Compute absolute paths once — producers return relative sub-path segments + const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; + const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; + const buildPreSize = buildInfo?.size ?? 0; + const preCleanOrphanedInfo = orphanedInfo.map( + (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) + ); + + await displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfo: preCleanOrphanedInfo, + }); + + const confirmed = await getConfirmation(argv); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } + + const [frameworkResult, buildResult] = await Promise.all([ + frameworkCache.cleanCache(ui5DataDir), + CacheManager.cleanCache(ui5DataDir), + ]); + + // Release the lock. Critical sections are done. + // The finally block will call releaseCleanupLock() again, which is a no-op (idempotent). + releaseCleanupLock(); + + // Clean additional resources that are safe to run outside the lock. + // For the framework cache this handles orphaned staging dirs from previous + // interrupted cleans. These are fully independent of any active operation. + const [additionalFrameworkResult] = await Promise.all([ + frameworkCache.cleanAdditional(ui5DataDir), + // The same interface. No-op + CacheManager.cleanAdditional(ui5DataDir), + ]); + const orphanedInfoWithAbsPaths = additionalFrameworkResult.map( + (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) + ); + + await displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfoWithAbsPaths, + }); + } finally { + releaseCleanupLock(); + } +} + +export default cacheCommand; diff --git a/packages/cli/lib/cli/commands/tree.js b/packages/cli/lib/cli/commands/tree.js index 2da80136012..7978b2e966e 100644 --- a/packages/cli/lib/cli/commands/tree.js +++ b/packages/cli/lib/cli/commands/tree.js @@ -183,6 +183,9 @@ tree.handler = async function(argv) { `Dependency graph generation took ${chalk.bold(elapsedTime)}`)); process.stderr.write("\n"); } + + // Release the process-coordination lock held by the graph + graph.destroy(); }; async function getElapsedTime(startTime) { diff --git a/packages/cli/lib/framework/utils.js b/packages/cli/lib/framework/utils.js index 799c8a35253..0599b824e47 100644 --- a/packages/cli/lib/framework/utils.js +++ b/packages/cli/lib/framework/utils.js @@ -1,6 +1,5 @@ -import path from "node:path"; import {graphFromStaticFile, graphFromPackageDependencies} from "@ui5/project/graph"; -import Configuration from "@ui5/project/config/Configuration"; +import {resolveUi5DataDir} from "@ui5/project/utils/dataDir"; export async function getRootProjectConfiguration(projectGraphOptions) { let graph; @@ -37,7 +36,7 @@ export async function createFrameworkResolverInstance({frameworkName, frameworkV return new Resolver({ cwd, version: frameworkVersion, - ui5DataDir: await utils.getUi5DataDir({cwd}) + ui5DataDir: await resolveUi5DataDir() }); } @@ -45,26 +44,15 @@ export async function frameworkResolverResolveVersion({frameworkName, frameworkV const Resolver = await utils.getFrameworkResolver(frameworkName, frameworkVersion); return Resolver.resolveVersion(frameworkVersion, { cwd, - ui5DataDir: await utils.getUi5DataDir({cwd}) + ui5DataDir: await resolveUi5DataDir() }); } -async function getUi5DataDir({cwd}) { - // 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(); - } - return ui5DataDir ? path.resolve(cwd, ui5DataDir) : undefined; -} - const utils = { getRootProjectConfiguration, getFrameworkResolver, createFrameworkResolverInstance, frameworkResolverResolveVersion, - getUi5DataDir }; let _utils; // For mocking of functions in unit tests and testing internal functions 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..20a652e96fa --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,463 @@ +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.acquireLockStub = sinon.stub().resolves(() => {}); + + t.context.frameworkCacheGetCacheInfo = sinon.stub(); + t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.frameworkCacheCleanAdditional = sinon.stub().resolves([]); + t.context.frameworkCacheGetOrphanedInfo = sinon.stub().resolves([]); + 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": { + hasActiveLocks: t.context.hasActiveLocksStub, + acquireLock: t.context.acquireLockStub, + getLockDir: sinon.stub().returns(path.join(TEST_UI5_DATA_DIR, "locks")), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + }, + "@ui5/project/ui5Framework/cache": { + getCacheInfo: t.context.frameworkCacheGetCacheInfo, + cleanCache: t.context.frameworkCacheCleanCache, + cleanAdditional: t.context.frameworkCacheCleanAdditional, + getOrphanedInfo: t.context.frameworkCacheGetOrphanedInfo, + }, + "@ui5/project/build/cache/CacheManager": { + default: class { + static getCacheInfo = t.context.buildCacheGetCacheInfo; + static cleanCache = t.context.buildCacheCleanCache; + static cleanAdditional = sinon.stub().resolves([]); + } + }, + "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"); +}); + +test.serial("ui5 cache clean: formats byte sizes correctly (< 1 KB)", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 500}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 500}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("500 B"), "Shows bytes format for size < 1 KB"); +}); + +test.serial("ui5 cache clean: shows orphaned framework data in pre-confirmation summary", async (t) => { + const {cache, argv, stderrWriteStub, yesnoStub, + frameworkCacheCleanCache, frameworkCacheGetOrphanedInfo} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: ".framework_to_delete_abcd", libraryCount: 5, versionCount: 2}, + ]); + frameworkCacheCleanCache.resolves(null); + + yesnoStub.resolves(true); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Orphaned framework data"), "Shows orphaned section in pre-confirm summary"); + t.true(allOutput.includes("incomplete previous clean"), "Shows orphaned context message"); + t.true(allOutput.includes("1 directory"), "Shows singular 'directory' for one orphan"); + t.true(allOutput.includes(".framework_to_delete_abcd"), "Shows orphaned dir path"); + t.true(allOutput.includes("2 versions of 5 libraries"), "Shows orphaned dir stats"); +}); + +test.serial("ui5 cache clean: shows orphaned framework data in post-clean summary", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheGetOrphanedInfo, + frameworkCacheCleanCache, frameworkCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves({path: "framework", libraryCount: 3, versionCount: 1}); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: ".framework_to_delete_ab12", libraryCount: 3, versionCount: 1}, + {path: ".framework_to_delete_cd34", libraryCount: 3, versionCount: 1}, + ]); + frameworkCacheCleanCache.resolves({path: "framework", libraryCount: 3, versionCount: 1}); + frameworkCacheCleanAdditional.resolves([ + {path: ".framework_to_delete_ab12", libraryCount: 3, versionCount: 1}, + {path: ".framework_to_delete_cd34", libraryCount: 3, versionCount: 1}, + ]); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Removed Orphaned framework data"), "Shows orphaned section in result"); + t.true(allOutput.includes("2 directories"), "Shows plural 'directories' for multiple orphans"); + t.true(allOutput.includes(".framework_to_delete_ab12"), "Shows first orphaned dir path"); + t.true(allOutput.includes(".framework_to_delete_cd34"), "Shows second orphaned dir path"); +}); + +test.serial("ui5 cache clean: shows orphaned-only success summary when no active framework", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheGetOrphanedInfo, + frameworkCacheCleanCache, frameworkCacheCleanAdditional} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: ".framework_to_delete_zz99", libraryCount: 10, versionCount: 3}, + ]); + frameworkCacheCleanCache.resolves(null); + frameworkCacheCleanAdditional.resolves([ + {path: ".framework_to_delete_zz99", libraryCount: 10, versionCount: 3}, + ]); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Orphaned framework data"), "Shows orphaned section"); + t.true(allOutput.includes("Cleaned Orphaned framework data"), "Success summary mentions orphaned data"); + t.false(allOutput.includes("UI5 Framework packages"), "Does not mention main framework when absent"); +}); diff --git a/packages/cli/test/lib/cli/commands/tree.js b/packages/cli/test/lib/cli/commands/tree.js index e08b338d236..657359cc7c6 100644 --- a/packages/cli/test/lib/cli/commands/tree.js +++ b/packages/cli/test/lib/cli/commands/tree.js @@ -33,6 +33,7 @@ test.beforeEach(async (t) => { traverseBreadthFirst: t.context.traverseBreadthFirst, getExtensionNames: t.context.getExtensionNames, getExtension: t.context.getExtension, + destroy: sinon.stub(), }; t.context.graph = { graphFromStaticFile: sinon.stub().resolves(fakeGraph), diff --git a/packages/cli/test/lib/framework/utils.js b/packages/cli/test/lib/framework/utils.js index 0ac6f4aaf37..1094bce26d9 100644 --- a/packages/cli/test/lib/framework/utils.js +++ b/packages/cli/test/lib/framework/utils.js @@ -1,7 +1,6 @@ import test from "ava"; import sinonGlobal from "sinon"; import esmock from "esmock"; -import path from "node:path"; test.beforeEach(async (t) => { // Tests either rely on not having UI5_DATA_DIR defined, or explicitly define it @@ -21,12 +20,7 @@ test.beforeEach(async (t) => { t.context.Openui5Resolver = sinon.stub(); t.context.Sapui5MavenSnapshotResolver = sinon.stub(); - t.context.ConfigurationGetUi5DataDirStub = sinon.stub().returns(undefined); - t.context.ConfigurationStub = { - fromFile: sinon.stub().resolves({ - getUi5DataDir: t.context.ConfigurationGetUi5DataDirStub - }) - }; + t.context.resolveUi5DataDirStub = sinon.stub().resolves(undefined); t.context.utils = await esmock.p("../../../lib/framework/utils.js", { "@ui5/project/graph": { @@ -42,7 +36,9 @@ test.beforeEach(async (t) => { "@ui5/project/ui5Framework/Sapui5MavenSnapshotResolver": { default: t.context.Sapui5MavenSnapshotResolver }, - "@ui5/project/config/Configuration": t.context.ConfigurationStub + "@ui5/project/utils/dataDir": { + resolveUi5DataDir: t.context.resolveUi5DataDirStub + } }); t.context._utils = t.context.utils._utils; }); @@ -144,11 +140,11 @@ test.serial("getFrameworkResolver: Invalid framework.name", async (t) => { test.serial("createFrameworkResolverInstance: Without ui5DataDir", async (t) => { const {createFrameworkResolverInstance} = t.context.utils; - const {sinon} = t.context; + const {sinon, resolveUi5DataDirStub} = t.context; const ResolverStub = sinon.stub().returns({}); sinon.stub(t.context._utils, "getFrameworkResolver").resolves(ResolverStub); - sinon.stub(t.context._utils, "getUi5DataDir").resolves(undefined); + resolveUi5DataDirStub.resolves(undefined); const result = await createFrameworkResolverInstance({ frameworkName: "", @@ -163,12 +159,7 @@ test.serial("createFrameworkResolverInstance: Without ui5DataDir", async (t) => "" ]); - t.is(t.context._utils.getUi5DataDir.callCount, 1); - t.deepEqual(t.context._utils.getUi5DataDir.getCall(0).args, [ - { - cwd: "my-project-path" - } - ]); + t.is(resolveUi5DataDirStub.callCount, 1); t.is(ResolverStub.callCount, 1); t.is(result, ResolverStub.getCall(0).returnValue); @@ -184,11 +175,11 @@ test.serial("createFrameworkResolverInstance: Without ui5DataDir", async (t) => test.serial("createFrameworkResolverInstance: With ui5DataDir", async (t) => { const {createFrameworkResolverInstance} = t.context.utils; - const {sinon} = t.context; + const {sinon, resolveUi5DataDirStub} = t.context; const ResolverStub = sinon.stub().returns({}); sinon.stub(t.context._utils, "getFrameworkResolver").resolves(ResolverStub); - sinon.stub(t.context._utils, "getUi5DataDir").resolves("my-ui5-data-dir"); + resolveUi5DataDirStub.resolves("my-ui5-data-dir"); const result = await createFrameworkResolverInstance({ frameworkName: "", @@ -203,12 +194,7 @@ test.serial("createFrameworkResolverInstance: With ui5DataDir", async (t) => { "" ]); - t.is(t.context._utils.getUi5DataDir.callCount, 1); - t.deepEqual(t.context._utils.getUi5DataDir.getCall(0).args, [ - { - cwd: "my-project-path" - } - ]); + t.is(resolveUi5DataDirStub.callCount, 1); t.is(ResolverStub.callCount, 1); t.is(result, ResolverStub.getCall(0).returnValue); @@ -224,13 +210,13 @@ test.serial("createFrameworkResolverInstance: With ui5DataDir", async (t) => { test.serial("frameworkResolverResolveVersion", async (t) => { const {frameworkResolverResolveVersion} = t.context.utils; - const {sinon} = t.context; + const {sinon, resolveUi5DataDirStub} = t.context; const resolveVersionStub = sinon.stub().resolves("1.111.1"); sinon.stub(t.context._utils, "getFrameworkResolver").resolves({ resolveVersion: resolveVersionStub }); - sinon.stub(t.context._utils, "getUi5DataDir").resolves(undefined); + resolveUi5DataDirStub.resolves(undefined); const result = await frameworkResolverResolveVersion({ frameworkName: "SAPUI5", @@ -250,48 +236,3 @@ test.serial("frameworkResolverResolveVersion", async (t) => { } ]); }); - -test.serial("getUi5DataDir: no value defined", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, undefined); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 1); -}); - -test.serial("getUi5DataDir: from environment variable", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - // Environment variable must be preferred over configuration value - ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); - process.env.UI5_DATA_DIR = ".ui5-data-dir-from-env-variable"; - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-env-variable")); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 0); -}); - -test.serial("getUi5DataDir: from Configuration", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-configuration")); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 1); -}); diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index b736e3be6b0..37387f76a2d 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -171,6 +171,8 @@ class BuildServer extends EventEmitter { // (e.g. Force-mode stale-cache errors). Otherwise the SQLite handle leaks // and subsequent fs.rm of the cache directory fails with EBUSY on Windows. this.#projectBuilder.closeCacheManager(); + // Explicitly destroy the ProjectGraph to release any process-coordination locks + this.#graph.destroy(); } } 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..da44399f1cd 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,99 @@ 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; + } + + /** + * Clean additional build cache resources that are safe to remove independently + * of any active process-coordination lock. + * + * @static + * @param {string} _ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise} + */ + static async cleanAdditional(_ui5DataDir) { + // No-op. Keep the cache clean interface aligned. + return []; + } } diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 653e6f7901b..1ff8ce47d13 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -1,29 +1,47 @@ +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 @ui5/project/graph/helpers/ui5Framework~enrichProjectGraph 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 +706,52 @@ 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 while those packages are actively referenced + * within the graph during the build/serve lifecycle. + * + * This is necessary because the graph holds references to framework package paths + * throughout its lifetime — not just during downloads. A cache clean after the download + * completes, but before the build/serve finishes, would delete files the graph is using. + * + * 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. + */ + async _preventCacheClean() { + // If already locked (e.g. enrichProjectGraph already called this), reuse the existing lock. + if (this.#lockRelease) { + return; + } + + const lockDir = getLockDir(this._ui5DataDir); + // Acquire our lock first, so any cache clean that starts concurrently will detect us and abort. + // Only then check whether a cache clean is already in progress — this order closes the race window. + 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(this._ui5DataDir, {include: CLEANUP_LOCK_NAME})) { + if (Date.now() >= deadline) { + this.#lockRelease?.(); + 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 +780,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,8 +789,7 @@ class ProjectGraph { selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], outputStyle = OutputStyleEnum.Default, - cache = Cache.Default, - ui5DataDir, + cache = Cache.Default }) { this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { @@ -739,6 +798,7 @@ class ProjectGraph { `Each graph can only be built or served once`); } this._builtOrServed = true; + await this._preventCacheClean(); const { default: ProjectBuilder } = await import("../build/ProjectBuilder.js"); @@ -751,13 +811,19 @@ class ProjectGraph { includedTasks, excludedTasks, outputStyle, cache }, - ui5DataDir, + ui5DataDir: this._ui5DataDir, }); - return await builder.buildToTarget({ + + const result = await builder.buildToTarget({ destPath, cleanDest, includedDependencies, excludedDependencies, dependencyIncludes, }); + + // Explicitly release the process-coordination lock now that the build has finished + this.destroy(); + + return result; } async serve({ @@ -765,8 +831,7 @@ class ProjectGraph { initialBuildIncludedDependencies = [], initialBuildExcludedDependencies = [], selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], - cache = Cache.Default, - ui5DataDir, + cache = Cache.Default }) { this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { @@ -775,6 +840,7 @@ class ProjectGraph { `Each graph can only be built or served once`); } this._builtOrServed = true; + await this._preventCacheClean(); const { default: ProjectBuilder } = await import("../build/ProjectBuilder.js"); @@ -788,7 +854,7 @@ class ProjectGraph { outputStyle: OutputStyleEnum.Default, cache }, - ui5DataDir, + ui5DataDir: this._ui5DataDir, }); const { default: BuildServer @@ -818,6 +884,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..f531619fd0b 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,11 @@ export default { * Promise resolving with the given graph instance to allow method chaining */ enrichProjectGraph: async function(projectGraph, options = {}) { + // Lock the graph so the entire lifecycle from this point — framework resolution, + // build, and serve — is treated as atomic by ui5 cache clean. Without this lock, + // cache clean could wipe framework packages or build data that the graph is actively using. + await projectGraph._preventCacheClean(); + const {workspace, snapshotCache} = options; const rootProject = projectGraph.getRoot(); const frameworkName = rootProject.getFrameworkName(); @@ -349,14 +353,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 +403,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..fb59433cf3f 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,26 @@ class AbstractInstaller { if (!ui5DataDir) { throw new Error(`Installer: Missing parameter "ui5DataDir"`); } - this._lockDir = path.join(ui5DataDir, "framework", "locks"); + this._ui5DataDir = ui5DataDir; + 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._ui5DataDir, {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 await 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..3019cabbef2 --- /dev/null +++ b/packages/project/lib/ui5Framework/cache.js @@ -0,0 +1,200 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import {getRandomValues} from "node:crypto"; + +const FRAMEWORK_DIR_NAME = "framework"; + +/** + * Prefix used for staging directories created during an atomic framework cache clean. + * The directory is renamed to this prefix + a random hex suffix before deletion so that + * the original path immediately becomes unavailable to concurrent processes. + */ +const STAGING_DIR_PREFIX = ".framework_to_delete_"; + +/** + * 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} frameworkDir Absolute path to the framework directory + * @returns {Promise<{libraries: number, versions: number}|null>} + * Null if the directory does not exist or contains no installed libraries. + */ +async function getPackageStats(frameworkDir) { + 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 frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + const stats = await getPackageStats(frameworkDir); + if (!stats) { + return null; + } + return { + path: FRAMEWORK_DIR_NAME, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; +} + +/** + * Scans ui5DataDir for orphaned staging directories left behind by previously + * interrupted clean operations (i.e. process killed after rename but before deletion). + * Returns stats per orphan without deleting anything. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} + */ +export async function getOrphanedInfo(ui5DataDir) { + let entries; + try { + entries = await fs.readdir(ui5DataDir, {withFileTypes: true}); + } catch { + return []; + } + + const orphans = entries.filter( + (e) => e.isDirectory() && e.name.startsWith(STAGING_DIR_PREFIX) + ); + + if (orphans.length === 0) { + return []; + } + + const results = await Promise.all(orphans.map(async (orphan) => { + const orphanDir = path.join(ui5DataDir, orphan.name); + const stats = await getPackageStats(orphanDir); + if (!stats) { + return null; + } + return { + path: orphan.name, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; + })); + + return results.filter(Boolean); +} + +/** + * Scans ui5DataDir for orphaned staging directories left behind by previously + * interrupted clean operations (i.e. process killed after rename but before deletion). + * + * Returns an array of result objects — one per orphaned directory found — each + * containing the path, library count and version count so the caller can include + * them in the cleanup summary. + * + * Deletion failures are swallowed per entry so one stuck directory does not prevent + * the others from being removed. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} + */ +export async function cleanAdditional(ui5DataDir) { + const orphans = await getOrphanedInfo(ui5DataDir); + + for (const orphan of orphans) { + const orphanDir = path.join(ui5DataDir, orphan.path); + try { + await fs.rm(orphanDir, {recursive: true, force: true}); + } catch { + // Ignore deletion errors + } + } + + return orphans; +} + +/** + * Clean the framework cache directory. + * + * Uses an atomic rename to make the framework directory disappear in a single + * filesystem operation. The caller is responsible for holding the cleanup lock + * for the full duration of this call: + * + * 1. Clear cacache's in-process memoization (no path needed — global operation). + * 2. Atomically rename framework/ to a hidden staging dir. + * After this point the original path no longer exists: concurrent builds will + * see it as absent and create a fresh framework/ directory. + * 3. Delete the staging dir recursively. Its contents are now fully private + * to this operation. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} + * Removal result, or null if no framework packages were installed. + */ +export async function cleanCache(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + const stats = await getPackageStats(frameworkDir); + if (!stats) { + return null; + } + + // Clear cacache's in-process memoization before the rename. + // clearMemoized() operates globally (no path argument) and is synchronous. + try { + const {clearMemoized} = await import("cacache"); + clearMemoized(); + } catch { + // cacache not available — no-op + } + + // Atomically rename framework/ to a staging directory. + // fs.rename is a single syscall and completes in microseconds. + // After this line the original path no longer exists. + const stagingDir = path.join( + ui5DataDir, + `${STAGING_DIR_PREFIX}${Buffer.from(getRandomValues(new Uint8Array(2))).toString("hex")}` + ); + await fs.rename(frameworkDir, stagingDir); + + await fs.rm(stagingDir, {recursive: true, force: true}); + + 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..8789424a57d --- /dev/null +++ b/packages/project/lib/utils/lock.js @@ -0,0 +1,183 @@ +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"; +const check = promisify(lockfile.check); +const unlock = promisify(lockfile.unlock); +const lock = promisify(lockfile.lock); +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. + */ +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 locks directory + * for the given UI5 data directory, indicating an ongoing download, installation, + * build, or server process. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data 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(ui5DataDir, {include, exclude} = {}) { + const lockDir = getLockDir(ui5DataDir); + 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; + } + + 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 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/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index e79e147ef29..0f3c8f98eb8 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -24,6 +24,7 @@ test.beforeEach(async (t) => { traverseDependents: function* (_projectName) { yield {project: rootProject}; }, + destroy: sinon.stub(), }; t.context.projectBuilder = { closeCacheManager: sinon.stub(), 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..3c02dcddf6b 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); @@ -1934,3 +2031,70 @@ test("Seal/isSealed", async (t) => { const extension = graph.getExtension("extension.b"); t.is(extension, undefined, "extension.b should not be added"); }); + +test.serial("_preventCacheClean(): calling it multiple times reuses the same lock", async (t) => { + const sinon = t.context.sinon; + + const releaseSpy = sinon.spy(); + const acquireLockSyncStub = sinon.stub().returns(releaseSpy); + + const ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { + "@ui5/logger": {getLogger: sinon.stub().returns(t.context.log)}, + "../../../lib/utils/lock.js": { + acquireLockSync: acquireLockSyncStub, + getLockDir: sinon.stub().returns("/test/locks"), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + }, + }); + + const graph = new ProjectGraph({rootProjectName: "test", ui5DataDir: TEST_UI5_DATA_DIR}); + + await graph._preventCacheClean(); + await graph._preventCacheClean(); + await graph._preventCacheClean(); + + t.is(acquireLockSyncStub.callCount, 1, + "acquireLockSync called exactly once — subsequent calls reuse the existing lock"); + t.is(releaseSpy.callCount, 0, "lock not released between calls"); + + esmock.purge(ProjectGraph); +}); + +test.serial("_preventCacheClean(): after destroy() releases the lock, a new call acquires a fresh one", async (t) => { + const sinon = t.context.sinon; + + const releaseSpy = sinon.spy(); + const acquireLockSyncStub = sinon.stub().returns(releaseSpy); + + const ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { + "@ui5/logger": {getLogger: sinon.stub().returns(t.context.log)}, + "../../../lib/utils/lock.js": { + acquireLockSync: acquireLockSyncStub, + getLockDir: sinon.stub().returns("/test/locks"), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + }, + }); + + const graph = new ProjectGraph({rootProjectName: "test", ui5DataDir: TEST_UI5_DATA_DIR}); + + // First acquisition + await graph._preventCacheClean(); + t.is(acquireLockSyncStub.callCount, 1, "lock acquired on first call"); + + // Repeated call — still the same lock, not re-acquired + await graph._preventCacheClean(); + t.is(acquireLockSyncStub.callCount, 1, "no second acquisition while lock is held"); + + // Release via destroy() + graph.destroy(); + t.is(releaseSpy.callCount, 1, "lock released by destroy()"); + + // After release, _preventCacheClean() must acquire a fresh lock + await graph._preventCacheClean(); + t.is(acquireLockSyncStub.callCount, 2, + "new lock acquired after destroy() — lock is not held anymore"); + + esmock.purge(ProjectGraph); +}); 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..6aa849418b8 --- /dev/null +++ b/packages/project/test/lib/ui5framework/cache.js @@ -0,0 +1,226 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import sinon from "sinon"; +import esmock from "esmock"; +import {getCacheInfo, cleanCache, cleanAdditional} from "../../../lib/ui5Framework/cache.js"; + +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; +}); + + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +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})); +} + +async function mkPackageIn(baseDir, project, library, version) { + const dir = path.join(baseDir, "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: renames then 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 + + // framework/ is gone — getCacheInfo returns null + t.is(await getCacheInfo(t.context.testDir), null); + + // No staging dirs remain after a successful clean + const entries = await fs.readdir(t.context.testDir); + t.false(entries.some((e) => e.startsWith(".framework_to_delete_")), + "no staging dirs remain after successful clean"); + + // packages/ is gone + await t.throwsAsync(fs.access(path.join(frameworkDir, "packages"))); +}); + +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 result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.is(result.libraryCount, 1); // sap.m deduplicated + t.is(result.versionCount, 2); + + t.is(await getCacheInfo(t.context.testDir), null); +}); + +test("cleanCache: does not include orphaned field in result", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.false(Object.prototype.hasOwnProperty.call(result, "orphaned"), + "cleanCache result does not include orphaned — use cleanAdditional for that"); +}); + +test("cleanCache: does not remove orphaned staging dirs — that is cleanAdditional's job", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const orphanDir = path.join(t.context.testDir, ".framework_to_delete_abcd"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.100.0"); + + await cleanCache(t.context.testDir); + + // Orphan is still present after cleanCache — cleanAdditional handles it + await t.notThrowsAsync(fs.access(orphanDir), "orphaned dir is not touched by cleanCache"); +}); + +// ─── cleanAdditional ────────────────────────────────────────────────────────── + +test("cleanAdditional: returns empty array when no orphaned staging dirs exist", async (t) => { + const result = await cleanAdditional(t.context.testDir); + t.deepEqual(result, []); +}); + +test("cleanAdditional: detects and removes orphaned staging dirs, reports them", async (t) => { + const orphanDir = path.join(t.context.testDir, ".framework_to_delete_abcd"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.100.0"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.110.0"); + + const result = await cleanAdditional(t.context.testDir); + + t.is(result.length, 1, "one orphaned dir reported"); + const orphanResult = result[0]; + t.true(orphanResult.path.startsWith(".framework_to_delete_"), "orphan path has staging prefix"); + t.is(orphanResult.libraryCount, 1); + t.is(orphanResult.versionCount, 2); + + await t.throwsAsync(fs.access(orphanDir), {code: "ENOENT"}, "orphaned staging dir removed"); +}); + +test("cleanAdditional: removes multiple orphaned staging dirs and reports each", async (t) => { + const orphan1 = path.join(t.context.testDir, ".framework_to_delete_1111"); + const orphan2 = path.join(t.context.testDir, ".framework_to_delete_2222"); + + await mkPackageIn(orphan1, "@openui5", "sap.m", "1.90.0"); + await mkPackageIn(orphan2, "@openui5", "sap.ui.core", "1.91.0"); + await mkPackageIn(orphan2, "@openui5", "sap.ui.core", "1.92.0"); + + const result = await cleanAdditional(t.context.testDir); + + t.is(result.length, 2, "two orphaned dirs reported"); + + const sorted = [...result].sort((a, b) => a.path.localeCompare(b.path)); + t.is(sorted[0].libraryCount, 1); + t.is(sorted[0].versionCount, 1); + t.is(sorted[1].libraryCount, 1); + t.is(sorted[1].versionCount, 2); + + await t.throwsAsync(fs.access(orphan1), {code: "ENOENT"}); + await t.throwsAsync(fs.access(orphan2), {code: "ENOENT"}); +}); + +test("cleanAdditional: orphaned dir deletion failure is non-fatal", async (t) => { + const orphanDir = path.join(t.context.testDir, ".framework_to_delete_fail"); + await mkPackageIn(orphanDir, "@openui5", "sap.m", "1.80.0"); + + const rmStub = sinon.stub().callsFake(async (p, opts) => { + if (p === orphanDir) { + throw new Error("simulated deletion failure"); + } + return fs.rm(p, opts); + }); + + const {cleanAdditional: cleanAdditionalMocked} = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, rm: rmStub}} + ); + + try { + const result = await t.notThrowsAsync(cleanAdditionalMocked(t.context.testDir)); + t.truthy(result, "cleanAdditional completes despite orphan deletion failure"); + } finally { + esmock.purge(cleanAdditionalMocked); + await fs.rm(orphanDir, {recursive: true, force: true}).catch(() => {}); + } +}); + 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..b81df02d82b --- /dev/null +++ b/packages/project/test/lib/utils/lock.js @@ -0,0 +1,315 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import sinon from "sinon"; +import esmock from "esmock"; +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) => { + // ui5DataDir — hasActiveLocks derives lockDir = ui5DataDir/locks/ internally + const ui5DataDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); + const lockDir = path.join(ui5DataDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + t.context.ui5DataDir = ui5DataDir; + t.context.lockDir = lockDir; + t.context.lockPath = path.join(lockDir, "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 clock = sinon.useFakeTimers({toFake: ["setInterval", "clearInterval", "setTimeout"]}); + try { + const release = acquireLockSync(t.context.lockPath); + const statBefore = await fs.stat(t.context.lockPath); + + // Release immediately — interval must stop + release(); + + // Advance past two interval periods — if the interval were still running it would + // call utimesSync on the (now deleted) file and throw an uncaught ENOENT. + await clock.tickAsync(LOCK_REFRESH_INTERVAL_MS * 2 + 1); + + 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"); + } finally { + clock.restore(); + } +}); + +test.serial("acquireLockSync: refresh interval keeps mtime fresh while lock is held", async (t) => { + // Start fake timers at the current real time so new Date() in the interval + // returns a timestamp that advances beyond the file's creation mtime. + const clock = sinon.useFakeTimers({ + now: Date.now(), + toFake: ["setInterval", "clearInterval", "setTimeout", "Date"], + }); + try { + const release = acquireLockSync(t.context.lockPath); + try { + const statBefore = await fs.stat(t.context.lockPath); + // Advance past one interval tick — Date now returns a later time, so + // utimesSync sets a later mtime that fs.stat will reflect. + await clock.tickAsync(LOCK_REFRESH_INTERVAL_MS + 1); + 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(); + } + } finally { + clock.restore(); + } +}); + +// ─── 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.ui5DataDir, "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.ui5DataDir), "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.ui5DataDir), "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.lockDir, "crashed-a.lock"); + const staleLockPathB = path.join(t.context.lockDir, "crashed-b.lock"); + + await fs.writeFile(staleLockPathA, ""); + await fs.writeFile(staleLockPathB, ""); + + // lock.js captures `check` and `unlock` at module load time via promisify(). + // Stubbing lockfileLib.check after load has no effect — use esmock so lock.js + // captures the stubs when it first runs promisify() on the injected module. + const checkStub = sinon.stub().yields(null, false); + // Simulate lockfile.unlock deleting the file, as the real implementation does. + const unlockStub = sinon.stub().callsFake((lockPath, cb) => { + fs.unlink(lockPath).catch(() => {}).finally(() => cb(null)); + }); + + const {hasActiveLocks: hasActiveLocksWithStubs} = await esmock.p( + "../../../lib/utils/lock.js", + {lockfile: {check: checkStub, unlock: unlockStub}} + ); + + try { + const result = await hasActiveLocksWithStubs(t.context.ui5DataDir); + + 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 { + esmock.purge(hasActiveLocksWithStubs); + } + }, +); + +test.serial( + "hasActiveLocks: keeps active lock and removes stale neighbor in same scan", + async (t) => { + const staleLockPath = path.join(t.context.lockDir, "stale.lock"); + const activeLockPath = path.join(t.context.lockDir, "active.lock"); + + await fs.writeFile(staleLockPath, ""); + await fs.writeFile(activeLockPath, ""); + + 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.ui5DataDir); + + 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.lockDir, "included.lock"); + const otherLockPath = path.join(t.context.lockDir, "other.lock"); + + await fs.writeFile(includedLockPath, ""); + await fs.writeFile(otherLockPath, ""); + + const checkStub = sinon.stub().yields(null, true); + const unlockStub = sinon.stub().yields(null); + + const {hasActiveLocks: hasActiveLocksWithStubs} = await esmock.p( + "../../../lib/utils/lock.js", + {lockfile: {check: checkStub, unlock: unlockStub}} + ); + + try { + const result = await hasActiveLocksWithStubs(t.context.ui5DataDir, {include: "included.lock"}); + + t.true(result, "included lock detected as active"); + + 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 { + esmock.purge(hasActiveLocksWithStubs); + } +}); + +test.serial("hasActiveLocks: honours exclude option (denylist)", async (t) => { + const excludedLockPath = path.join(t.context.lockDir, "excluded.lock"); + const otherLockPath = path.join(t.context.lockDir, "other.lock"); + + await fs.writeFile(excludedLockPath, ""); + await fs.writeFile(otherLockPath, ""); + + const checkStub = sinon.stub().yields(null, true); + const unlockStub = sinon.stub().yields(null); + + const {hasActiveLocks: hasActiveLocksWithStubs} = await esmock.p( + "../../../lib/utils/lock.js", + {lockfile: {check: checkStub, unlock: unlockStub}} + ); + + try { + const result = await hasActiveLocksWithStubs(t.context.ui5DataDir, {exclude: "excluded.lock"}); + + t.true(result, "the non-excluded lock is detected"); + + 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 { + esmock.purge(hasActiveLocksWithStubs); + } +});