diff --git a/CACHE_CLEAN_SUMAMRY.md b/CACHE_CLEAN_SUMAMRY.md new file mode 100644 index 00000000000..dca797e93cc --- /dev/null +++ b/CACHE_CLEAN_SUMAMRY.md @@ -0,0 +1,88 @@ +# Lessons Learned — `ui5 cache clean` + +This document captures what was learned building the "Clean cache command" so each topic can be broken down and decided individually later. + +What started as a simple `rm -rf ~/.ui5/framework` replacement grew into a cross-package concurrency, lifecycle, and refactoring problem. The scope creep is the central lesson — every "simple deletion" of a shared resource pulls in coordination with every process that reads or writes it. + +--- + +## 1. General locking and locks concept + +The complete locking design is documented in [doc-process-coordination-locks.md](./doc-process-coordination-locks.md). That document covers: why file-based locks are needed (separate OS processes), why the `lockfile` package was chosen, the two lock types (graph lock and cleanup lock), their lifecycles, staleness and mtime refresh mechanics, sync vs async acquisition trade-offs, release safety rules, and open questions around `ProjectGraph` ownership and v4 backward compatibility. + +**What we struggled with and learned:** + +Locking turned out to be the hardest part of this feature. We kept discovering new unprotected windows — first only downloads, then installs, then the whole graph lifetime. The core lesson was a shift in framing: **"where to lock" is really "who holds the resource, and for how long"** — not "which function writes or deletes it." Once we identified `ProjectGraph` as the right lock owner (it holds live references for its entire lifetime), the rest followed. + +The other recurring challenge was getting release right. Every early-exit path and every async callback inside a protected section is a potential deadlock. We introduced a regression during the PR by dropping a single `await` keyword — `return callback()` instead of `return await callback()` — which released the lock before the async work finished. This kind of bug is silent and hard to spot in review. + +The open questions that still need team decisions are in [Unresolved Questions](./doc-process-coordination-locks.md#unresolved-questions-and-bikeshedding) of the RFC. +--- + +## 2. Performance & UX + +### 2.1 What information to display to the end user + +**What we did — and reversed twice:** +- v1: show **byte size** of the cache. For big caches it might take significant amount of time to compute the size. Native OS variants are also slow and for different platforms there are different tools. +- v2: show **file count** — still slow (traversal of ~2M files) and "2 million files" is meaningless to a user. +- v3 (final): show **"X versions of Y libraries"** — a shallow directory traversal (project → library → version), fast and meaningful. + +We also added: a pre-confirmation summary, a `--yes` flag and a post-clean success line. + +**Lesson:** "How much will this free?" is expensive to answer accurately and not even what the user cares about. The useful signal was *what* is being removed (libraries/versions), not *how many bytes*. We spent significant effort computing a number we ultimately threw away. **Decide the UX metric before implementing the measurement.** + +### 2.2 What, where, and how to purge data + +Three distinct storage layers each needed a different purge strategy: + +- **Memory** — `cacache`'s in-process memoization. Cleared via `clearMemoized()` (global, synchronous) before the rename. +- **DB** — the SQLite build cache (`buildCache/v0_7/cache.db`). Cleaned via `clearAllRecords()` inside a transaction. Crucially, we made it **version-aware**: only the current `CACHE_VERSION` is purged, because we cannot know the schema of a future cache version ("wipe everything" is not robust across schema versions). The DB *file* is intentionally kept; only records are deleted. +- **File system** — the `framework/` directory. We adopted an **atomic-rename-then-delete** strategy: rename `framework/` → `.framework_to_delete_/` (one fast syscall, makes the path disappear instantly), release the lock, then delete the staging dir outside the lock. This minimizes lock-hold time and lets a concurrent build see the path as simply absent. + +The rename strategy introduced a new concept: **orphaned staging dirs** (a clean interrupted after rename but before delete). We added `cleanAdditional()` to sweep these up independently — they're safe to remove any time, outside the lock. + +**Lesson:** Three storage backends = three purge semantics. The atomic rename was the key insight for the file system (short lock, no corruption), but it *created* a new failure mode (orphans) that needed its own cleanup path. Every optimization added a corner. The DB version-awareness is a genuinely important correctness constraint that should survive into any future concept. + +--- + +## 3. Refactoring + +### 3.1 DRY — installers: consolidate locking + +**What we did:** `AbstractInstaller` (base for npm + maven installers) now imports the shared lock utilities instead of promisifying `lockfile` inline per call. `_synchronize` centralizes: acquire → check-for-cleanup → run callback → release. + +**Open item flagged during review:** The `framework/` directory name is still hardcoded in each installer rather than abstracted in `AbstractInstaller`. The clean concern and the install concern both know about `framework/` independently. The proposed direction: abstract the `framework/` location and the locking into `AbstractInstaller`, then have the cache cleaner reuse that knowledge — so there's one source of truth for "where framework files live and how they're locked." + +**Lesson:** The installers and the cleaner are two sides of the same resource but evolved in different namespaces (`ui5Framework/` vs the CLI command). They share path + lock knowledge but not code. + +### 3.2 Resolution of UI5 data dir + +**What we did:** Introduced [`resolveUi5DataDir()`](../packages/project/lib/utils/dataDir.js) as the single resolver (env var → config → `~/.ui5` default, always returns an absolute path). Removed a duplicate `getUi5DataDir` from `packages/cli/lib/framework/utils.js`. + +**Still inconsistent (flagged):** Resolution still happens **in-place** in a few spots. The Resolvers/Installers (e.g. `Openui5Resolver`) keep a fallback `ui5DataDir || path.join(os.homedir(), ".ui5")` because they're publicly exported and can be called without a pre-resolved dir. So there isn't *one* resolution path — there's a util plus defensive fallbacks at the public API boundary. + +**Lesson:** "One utility" isn't the same as "one resolution path." Public API entry points force defensive fallbacks. A clean concept needs to decide: is `ui5DataDir` resolved once at the outermost boundary and threaded through, or resolved lazily wherever needed? We have a mix, and the mix is the problem. This also introduced backward incompatibilities. + +### 3.3 Location of cached resources, locks, etc. + +**What we did:** Consolidated all lock files into `~/.ui5/locks/` (previously `~/.ui5/framework/locks/`). As the lock scope grew beyond framework (to build/serve/cleanup), a framework-scoped lock dir no longer made sense. + +**Lesson:** The lock directory location is a consequence of lock *scope*. When scope expanded, the location had to move. This is a symptom of the same root issue: scope kept growing, and physical layout followed reactively. + +**Open question (RandomByte):** Moving all locks to `~/.ui5/locks/` creates a **compatibility concern** with previous CLI versions that still write framework-installer locks to `~/.ui5/framework/locks/`. If a user runs two CLI versions in parallel — e.g. a project pinned to an older version alongside a global newer version — the old version won't see the new locks and vice versa. + +One option is to keep framework-dependency-related locks under `~/.ui5/framework/locks/` and only place new, broader-scope locks (build, serve, cache-clean) under `~/.ui5/locks/`. This would preserve backward compatibility for the installer lock path while still enabling the new coordination locks. + +**Analysis of mixed-version scenarios:** The actual cross-version impact is more limited than it first appears. Locks are only meaningful when shared between cooperating processes of the *same* version. The mixed-version scenarios break down as: + +- **v5 only or v4 only** — nothing breaks; everything works as expected. +- **v4 and v5 in parallel** — v5's `cache clean` is the only command that uses the new lock dir. v5's locks will not prevent v4 from downloading or installing framework resources (v4 doesn't know about `~/.ui5/locks/`). The only real clash is: running v5's `cache clean` while v4's `serve` or `build` is active. In the worst case, some framework resources are deleted mid-use and the v4 command must be re-run. + +**Remaining decision:** Is the v4+v5 concurrent-clean risk acceptable, or does it warrant keeping framework locks at their old path? The risk is bounded (a re-run is sufficient recovery), but it is user-visible. + +--- + +## Summary + +Every sub-topic in the comment traces back to one root: **deleting a shared resource forces you to model every reader and writer of that resource, across processes, for their full lifetime.** The complexity wasn't in the deletion — it was in proving nobody else is mid-flight. Restarting the concept should begin from that model (who owns the framework cache, its lock contract, and its lifecycle), and let the command, the installers, and the graph consume that one contract — rather than each discovering the coordination need independently, which is how we got here. diff --git a/doc-process-coordination-locks.md b/doc-process-coordination-locks.md new file mode 100644 index 00000000000..c96b16de990 --- /dev/null +++ b/doc-process-coordination-locks.md @@ -0,0 +1,184 @@ +- Start Date: 2026-07-16 +- RFC PR: [#1394](https://github.com/UI5/cli/pull/1394) +- Issue: - +- Affected components + + [ ] [ui5-builder](./packages/builder) + + [x] [ui5-server](./packages/server) + + [x] [ui5-cli](./packages/cli) + + [ ] [ui5-fs](./packages/fs) + + [x] [ui5-project](./packages/project) + + [ ] [ui5-logger](./packages/logger) + + +# RFC 0019 Process-Coordination Locks + +## Summary + +Introduce a shared, file-based advisory locking mechanism across `@ui5/project` and `@ui5/cli` so that `ui5 cache clean` can safely delete framework packages and build cache data without corrupting concurrent `ui5 build`, `ui5 serve`, or programmatic `@ui5/*` API consumers. + +## Motivation + +`ui5 cache clean` deletes files inside `~/.ui5/` that may be actively used by other running UI5 CLI processes. Without coordination, a cache clean can: + +- Delete framework packages while a concurrent `ui5 build` is installing or reading them, causing `ENOENT` errors mid-build. +- Clear the SQLite build-cache database while a running `ui5 serve` holds it open, corrupting its WAL state. +- Race against a concurrent installer, resulting in a partially-installed framework directory. + +UI5 CLI processes are **separate OS processes**, not async tasks within one Node.js event loop. An in-process mutex is therefore insufficient. Cross-process coordination requires file-based advisory locks that survive across PIDs and are visible in the filesystem. + +## Detailed design + +### Lock directory + +All process-coordination lock files live in a single shared directory: + +``` +~/.ui5/locks/ +``` + +This location is configurable via `UI5_DATA_DIR` / `ui5 config set ui5DataDir` — the lock directory is always `path.join(ui5DataDir, "locks")`. + +Lock files are created with the `lockfile` npm package, which provides POSIX-compatible exclusive file creation and a signal-exit handler that removes all known locks on process exit. + +### Why `lockfile` + +The `lockfile` package was already used by the framework installers (`AbstractInstaller`) for per-package installation locks before this RFC. Reusing it for the new process-coordination locks keeps the dependency footprint flat and ensures consistent behavior across all locking in the codebase. + +Key properties that make it suitable: + +- **Staleness support** — every lock carries a `stale` threshold; locks older than the threshold are treated as dead (left by a crashed process) and can be cleaned up without manual intervention. +- **Signal-exit integration** — the package registers handlers for `SIGINT`, `SIGTERM`, and `exit` that synchronously remove all locks it knows about, providing automatic cleanup on both graceful shutdown and unexpected termination. +- **Sync and async variants** — `lockfile.lock` (async) and `lockfile.lockSync` (sync) share the same staleness semantics, making them interchangeable from a correctness perspective while allowing each call site to pick the variant that fits its execution context. + +### Lock types + +Two categories of lock are used: + +| Lock | File pattern | Held by | Checked by | +|---|---|---|---| +| Graph lock | `graph-{pid}-{randomHex}.lock` | `ProjectGraph` instance (for its full lifetime) | `ui5 cache clean` before deletion | +| Cleanup lock | `cache-cleanup.lock` | `ui5 cache clean` | Framework installer (`AbstractInstaller`) before package operations | + +### Graph lock lifecycle + +`ProjectGraph._preventCacheClean()` acquires a graph lock so that `ui5 cache clean` can detect any active build or serve process. The lock: + +- Is acquired asynchronously via `acquireLock` (unique path, no contention possible). +- Refreshes its mtime every `LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6` so it does not appear stale during long-running operations. +- Is released in `ProjectGraph.destroy()`, which must be called explicitly by callers when the graph is no longer needed. +- Is automatically removed on abnormal process exit by the `lockfile` signal-exit handler. + +The lock path encodes the PID and a random hex suffix (`graph-{pid}-{randomHex}.lock`) to guarantee uniqueness across concurrent instances. + +#### Why `ProjectGraph` is the lock owner + +During the development of `ui5 cache clean`, a key design question was: where exactly should the lock be acquired? Candidates considered included the CLI command itself, individual install methods, individual build or serve methods, and specific cached resources. + +The eventual answer: **the `ProjectGraph` instance is the correct lock owner**, because the graph holds live references to cached resources for its entire lifetime — not just during downloads or builds. Once a graph is constructed, it references framework package paths that must not be deleted until the graph is destroyed. The invariant is simple: *while a `ProjectGraph` is alive, `cache clean` must not proceed*. + +#### What "alive" means for `ProjectGraph` + +In the context of the UI5 CLI, a graph's lifetime matches a command invocation: + +- `ui5 build` — graph lives for the duration of the build. +- `ui5 serve` — graph lives for the lifetime of the dev server. +- `ui5 tree` — graph lives while the dependency tree is computed and printed. + +In all these cases, the CLI command is the obvious caller of `destroy()` at the end of the operation. + +#### Complexity with programmatic API usage + +`ProjectGraph` is a public export of `@ui5/project`. Developers can construct and use graphs directly without going through the CLI. This raises questions that are not fully resolved by the current implementation: + +- A graph used as an API may **live longer than necessary** if the consumer does not call `destroy()`. +- The responsibility for calling `destroy()` falls on the API consumer, but there is no enforced contract. The current implementation relies on documentation and the `lockfile` signal-exit backstop. + +These questions are captured in the Unresolved Questions section. + +### Cleanup lock lifecycle + +`ui5 cache clean` acquires `cache-cleanup.lock` before any deletion: + +1. Acquire `cache-cleanup.lock` (async, no wait — if already held, the command was invoked twice concurrently). +2. Scan `~/.ui5/locks/` for any graph lock (excluding `cache-cleanup.lock` itself). If any non-stale graph lock exists, abort with a user-visible error. +3. Perform deletion (atomic rename of `framework/` + SQLite `clearAllRecords()`). +4. Release `cache-cleanup.lock`. + +The framework installer (`AbstractInstaller._synchronize`) checks for `cache-cleanup.lock` after acquiring its own per-package lock. If the cleanup lock is active, the install is aborted with a clear error message. + +### Staleness + +Locks older than `LOCK_STALE_MS = 60000` ms are treated as dead (from a crashed process) and garbage-collected by `hasActiveLocks`. The mtime-refresh interval (`LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6 = 36000` ms) ensures a live lock is always refreshed before it appears stale. + +### Sync vs async acquisition + +Two variants are provided in `packages/project/lib/utils/lock.js`: + +**Async (preferred):** + +`acquireLock(lockPath, {wait, retries})` — asynchronous; non-blocking; supports `opts.wait` (poll up to N ms before giving up) and `opts.retryWait` (delay between retries). Multiple concurrent callers can queue without blocking the event loop. This is the correct choice for all code that can be async — in particular, for operations where lock contention is possible (e.g. multiple concurrent package downloads). + +**Sync (only when async is impossible):** + +`acquireLockSync(lockPath, {retries})` — synchronous; blocks the event loop for the duration of the acquisition. Its key benefit is that it makes lock acquisition atomic from the perspective of the current thread — no other JS code can run between the call and the return. This is the only viable option when: + +- The call site is inside a **constructor** (JS constructors cannot be `async`). +- The call site is inside a **synchronous method** with no path to `async`. + +The critical limitation of `lockSync` is that it **cannot wait** for a contended lock. The underlying `lockfile` library throws if `opts.wait` or `opts.retryWait` are passed to `lockSync`: + +``` +'opts.wait not supported sync for obvious reasons' +``` + +It either acquires immediately or throws. The only mitigation is `opts.retries` (a fixed-count retry loop, each blocking the event loop). This is acceptable only when the lock path is guaranteed unique per process (no realistic contention), such as a path encoding `{pid}-{randomHex}`. + +### Release safety + +Lock release must be guaranteed regardless of how control exits a protected section: + +- The release function is **idempotent** — safe to call multiple times from both an explicit path and a `finally` block. +- All protected sections use `try { ... } finally { release(); }`. +- Async callbacks inside a lock must be `await`ed: `return await callback()`, not `return callback()`. Without `await`, the `finally` fires before the async work completes, releasing the lock prematurely. +- Abnormal termination is covered by the `lockfile` signal-exit handler. + +### `hasActiveLocks` utility + +```js +hasActiveLocks(ui5DataDir, {include?, exclude?}) +``` + +Scans `~/.ui5/locks/` for non-stale lock files. The `include` and `exclude` options allow filtering by filename (e.g. exclude the caller's own lock when checking for others). Stale lock files are garbage-collected as a side effect. + +## How we teach this + +- The lock contract is documented in JSDoc on `ProjectGraph`, `_preventCacheClean()`, `acquireLockSync`, `acquireLock`, and `AbstractInstaller._synchronize`. +- Callers of `ProjectGraph` that build or serve (CLI commands, `BuildServer`) must call `graph.destroy()` when the graph is no longer needed to release the lock. +- The `ui5 cache clean` command displays a clear error when an active lock is detected, telling the user to stop running processes first. + +## Drawbacks + +- Adds a new runtime dependency (`lockfile`) and a new `~/.ui5/locks/` directory in the user's data dir. +- `ProjectGraph.destroy()` must be called explicitly. JS has no destructor; forgetting to call it leaks the lock until process exit (covered by the signal-exit backstop, but not ideal for long-lived processes). +- `ui5 cache clean` cannot protect against a build that started but has not yet reached `_preventCacheClean()` — there is a narrow startup window (~200–400 ms) where the race can be lost. The build recovers gracefully by re-downloading, but the user sees an unexpected full re-download. + +## Alternatives + +**In-process mutex:** Does not work — `ui5 build` and `ui5 cache clean` are separate OS processes. + +**Single cleanup lock in the CLI command only:** Protects the clean operation but does not prevent an installer from running into a half-deleted `framework/` directory. + +**No locking — atomic rename only:** The atomic rename of `framework/` prevents corruption of files already opened, but does not prevent a concurrent installer from writing into the newly-absent path between the rename and the installer's own lock acquisition. + +**Keep framework locks at `~/.ui5/framework/locks/`:** Preserves backward compatibility with v4 (which writes installer locks there). A mixed v4+v5 scenario means v5's `cache clean` cannot see v4's installer locks — the worst case is a re-run of the v4 command. This is an open decision; see Unresolved Questions. + +## Unresolved Questions and Bikeshedding + +*This section should be removed (i.e. resolved) before merging* + +1. **Lock granularity:** Should the lock live at the CLI command layer (`@ui5/cli`) or inside `@ui5/project`? Locking only in the CLI command misses programmatic API consumers; locking inside `@ui5/project` adds complexity to the library. The current design locks at the `ProjectGraph` level inside `@ui5/project`. + +2. **Lock directory location — v4 compatibility:** v4 writes framework installer locks to `~/.ui5/framework/locks/`. v5 moved them to `~/.ui5/locks/`. The two versions are invisible to each other. Should v5 keep framework-installer locks at the old path for backward compatibility, placing only the new broader-scope locks (build, serve, cache-clean) in `~/.ui5/locks/`? + +3. **`ProjectGraph` lock ownership for API consumers:** `ProjectGraph` is a public export. When a developer uses it directly (not via the CLI), who is responsible for acquiring and releasing the lock? Should `@ui5/project` always lock — even for read-only graph traversal — or should locking be opt-in? Should the library enforce explicit `destroy()` calls rather than relying on the signal-exit backstop? The current implementation locks unconditionally and documents the `destroy()` contract, but does not enforce it. + diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index aff2f642c0c..22a00ad7e5a 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 required framework dependencies will be re-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 will clean 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 67d2a28a0e1..8c2c29af03c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18349,7 +18349,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 3c2ec93c3e6..a48b86c8ff2 100644 --- a/packages/cli/lib/cli/commands/tree.js +++ b/packages/cli/lib/cli/commands/tree.js @@ -188,6 +188,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 c61cd5231af..2444778b3d2 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 22cc4bd873a..e460d13fa9e 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -199,6 +199,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..e73b5564334 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 {CLEANUP_LOCK_NAME, hasActiveLocks, getLockDir, acquireLock} 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 = await acquireLock(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 5ecb20073dc..e4fd1502c68 100644 --- a/packages/project/lib/graph/projectGraphBuilder.js +++ b/packages/project/lib/graph/projectGraphBuilder.js @@ -4,6 +4,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) { @@ -135,7 +136,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 313bc5db619..808a7bb4b6b 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 0abcf7765a9..13e03b28c4e 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 9e9200a978d..3886d7fd929 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -22,7 +22,7 @@ async function drainUntil(clock, statusEvents, predicate, {maxTicks = 100} = {}) // Graph with a single library dependency behind the root project. Used by the // BUILDING → VALIDATING → … tests to leave one INITIAL project after the build // cycle, which is the trigger for the post-build validation pass. -function makeGraphWithLib() { +function makeGraphWithLib(sinon) { const rootProject = {getName: () => "root.project"}; const libProject = {getName: () => "library.x"}; const graph = { @@ -34,6 +34,7 @@ function makeGraphWithLib() { yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon ? sinon.stub() : () => {}, }; return {rootProject, libProject, graph}; } @@ -68,6 +69,7 @@ test.beforeEach(async (t) => { traverseDependents: function* (_projectName) { yield {project: rootProject}; }, + destroy: sinon.stub(), }; t.context.projectBuilder = { closeCacheManager: sinon.stub(), @@ -340,7 +342,7 @@ test.serial( // Augment the graph with a single library dependency so the build cycle leaves // one INITIAL project behind, which is exactly the trigger for VALIDATING. - const {graph} = makeGraphWithLib(); + const {graph} = makeGraphWithLib(sinon); // validateCaches: report library.x's cache as fresh — usesCache=true triggers the // promote-to-FRESH path that closes out the validation pass with no stale projects. @@ -393,7 +395,7 @@ test.serial( "serve-status: VALIDATING → STALE when validation finds a stale cache", async (t) => { const {BuildServer, sinon, clock} = t.context; - const {graph} = makeGraphWithLib(); + const {graph} = makeGraphWithLib(sinon); // usesCache=false → project must stay INITIAL → final state is STALE. const projectBuilder = { @@ -446,6 +448,7 @@ test.serial( yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon.stub(), }; const validationError = new Error("validateCaches blew up"); @@ -507,6 +510,7 @@ test.serial( yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon.stub(), }; // Hold the validation pass open so the reader request can land mid-VALIDATING. @@ -605,6 +609,7 @@ test.serial( yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon.stub(), }; let releaseValidation; @@ -698,6 +703,7 @@ test.serial( yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon.stub(), }; let releaseValidation; @@ -790,6 +796,7 @@ test.serial( traverseDependents: function* (projectName) { yield {project: projectsByName[projectName]}; }, + destroy: sinon.stub(), }; // Track concurrent invocations of projectBuilder.build. The second 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..91d89fc92e5 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": { + acquireLock: sinon.stub().resolves(() => {}), + 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 acquireLockStub = sinon.stub().resolves(releaseSpy); + + const ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { + "@ui5/logger": {getLogger: sinon.stub().returns(t.context.log)}, + "../../../lib/utils/lock.js": { + acquireLock: acquireLockStub, + 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(acquireLockStub.callCount, 1, + "acquireLock 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 acquireLockStub = sinon.stub().resolves(releaseSpy); + + const ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { + "@ui5/logger": {getLogger: sinon.stub().returns(t.context.log)}, + "../../../lib/utils/lock.js": { + acquireLock: acquireLockStub, + 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(acquireLockStub.callCount, 1, "lock acquired on first call"); + + // Repeated call — still the same lock, not re-acquired + await graph._preventCacheClean(); + t.is(acquireLockStub.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(acquireLockStub.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); + } +});