From 02baa1484706dc5227c3d3074a20e1f21105237c Mon Sep 17 00:00:00 2001 From: Malte Brunnlieb Date: Thu, 8 Jan 2026 11:49:28 +0100 Subject: [PATCH 1/2] #1602: Being offline can block ide startup --- .github/copilot-instructions.md | 2 +- CHANGELOG.adoc | 1 + .../tools/ide/tool/LocalToolCommandlet.java | 33 ++++++- ...ackageManagerBasedLocalToolCommandlet.java | 5 +- .../devonfw/tools/ide/tool/python/Python.java | 5 +- .../repository/AbstractToolRepository.java | 16 ++- .../ide/commandlet/InstallCommandletTest.java | 98 +++++++++++++++++++ 7 files changed, 144 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fd5060b12e..72f09477d2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,7 +14,7 @@ The standard configuration of the IDEasy is maintained in the repository https:/ ## Test Execution -- All tests can be executed by `mvn clean test` +- All tests can be executed by `mvn clean test` in the module of interest, e.g. the `cli` folder - All integration tests can be executed by executing the script `cli/src/test/all-tests.sh` ## Commit Messages diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 78ef67e4ba..ad093a4713 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -7,6 +7,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1653[#1653]: Implementation of getEdition and getVersion for Docker +* https://github.com/devonfw/IDEasy/issues/1602[#1602]: Being offline can block ide startup The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/39?closed=1[milestone 2026.01.001]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java index 3f650ae007..cc3a8c570e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java @@ -6,6 +6,7 @@ import java.util.Collection; import java.util.Set; +import com.devonfw.tools.ide.cli.CliOfflineException; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.io.FileAccess; @@ -198,8 +199,13 @@ public ToolInstallation installTool(ToolInstallRequest request) { } } } - performToolInstallation(request, installationPath); - return createToolInstallation(installationPath, resolvedVersion, true, processContext, additionalInstallation); + VersionIdentifier actualInstalledVersion = performToolInstallation(request, installationPath, resolvedVersion); + // If offline and could not download, actualInstalledVersion will be the old version, not resolvedVersion + // In that case, we need to recalculate the installation path for the actually installed version + if (!actualInstalledVersion.equals(resolvedVersion)) { + installationPath = getInstallationPath(edition, actualInstalledVersion); + } + return createToolInstallation(installationPath, actualInstalledVersion, true, processContext, additionalInstallation); } /** @@ -211,13 +217,29 @@ public ToolInstallation installTool(ToolInstallRequest request) { * * @param request the {@link ToolInstallRequest}. * @param installationPath the target {@link Path} where the {@link #getName() tool} should be installed. + * @param resolvedVersion the {@link VersionIdentifier} that should be installed. + * @return the {@link VersionIdentifier} of the version that was actually installed. In offline scenarios where download fails, this may be different from + * {@code resolvedVersion} (returning the existing installed version instead). */ - protected void performToolInstallation(ToolInstallRequest request, Path installationPath) { + protected VersionIdentifier performToolInstallation(ToolInstallRequest request, Path installationPath, VersionIdentifier resolvedVersion) { FileAccess fileAccess = this.context.getFileAccess(); ToolEditionAndVersion requested = request.getRequested(); - VersionIdentifier resolvedVersion = requested.getResolvedVersion(); - Path downloadedToolFile = downloadTool(requested.getEdition().edition(), resolvedVersion); + Path downloadedToolFile; + try { + downloadedToolFile = downloadTool(requested.getEdition().edition(), resolvedVersion); + } catch (CliOfflineException e) { + // If we are offline and cannot download, check if we can continue with an existing installation + ToolEditionAndVersion installed = request.getInstalled(); + if ((installed != null) && (installed.getResolvedVersion() != null)) { + this.context.warning("Cannot download {} in version {} because we are offline. Continuing with already installed version {}.", + this.tool, resolvedVersion, installed.getResolvedVersion()); + // Return the existing installed version to indicate fallback + return installed.getResolvedVersion(); + } + // No existing installation available, re-throw the exception + throw e; + } boolean extract = isExtract(); if (!extract) { this.context.trace("Extraction is disabled for '{}' hence just moving the downloaded file {}.", this.tool, downloadedToolFile); @@ -233,6 +255,7 @@ protected void performToolInstallation(ToolInstallRequest request, Path installa fileAccess.extract(downloadedToolFile, installationPath, this::postExtract, extract); this.context.writeVersionFile(resolvedVersion, installationPath); this.context.debug("Installed {} in version {} at {}", this.tool, resolvedVersion, installationPath); + return resolvedVersion; } /** diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java index 2dae294ef8..e845e0befb 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java @@ -161,12 +161,13 @@ public VersionIdentifier getInstalledVersion() { } @Override - protected final void performToolInstallation(ToolInstallRequest request, Path installationPath) { + protected final VersionIdentifier performToolInstallation(ToolInstallRequest request, Path installationPath, VersionIdentifier resolvedVersion) { PackageManagerRequest packageManagerRequest = new PackageManagerRequest(PackageManagerRequest.TYPE_INSTALL, getPackageName()) - .setProcessContext(request.getProcessContext()).setVersion(request.getRequested().getResolvedVersion()); + .setProcessContext(request.getProcessContext()).setVersion(resolvedVersion); runPackageManager(packageManagerRequest, true).failOnError(); this.installedVersion.invalidate(); + return resolvedVersion; } /** diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java b/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java index 78df0ea958..0e51d1efc7 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java @@ -35,9 +35,8 @@ public Python(IdeContext context) { } @Override - protected void performToolInstallation(ToolInstallRequest request, Path installationPath) { + protected VersionIdentifier performToolInstallation(ToolInstallRequest request, Path installationPath, VersionIdentifier resolvedVersion) { - VersionIdentifier resolvedVersion = request.getRequested().getResolvedVersion(); if (resolvedVersion.compareVersion(PYTHON_MIN_VERSION).isLess()) { throw new CliException("Python version must be at least " + this.PYTHON_MIN_VERSION); } @@ -53,7 +52,7 @@ protected void performToolInstallation(ToolInstallRequest request, Path installa renameVenvFolderToPython(fileAccess, softwarePath, installationPath); this.context.writeVersionFile(resolvedVersion, installationPath); createWindowsSymlinkBinFolder(fileAccess, installationPath); - this.context.debug("Installed {} in version {} at {}", this.tool, resolvedVersion, installationPath); + return resolvedVersion; } @Override diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/AbstractToolRepository.java b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/AbstractToolRepository.java index fc7b461e75..f1cc935a36 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/repository/AbstractToolRepository.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/repository/AbstractToolRepository.java @@ -71,10 +71,6 @@ public Path download(String tool, String edition, VersionIdentifier version, Too */ public Path download(UrlDownloadFileMetadata metadata) { - VersionIdentifier version = metadata.getVersion(); - if (context.isOffline()) { - throw CliOfflineException.ofDownloadOfTool(metadata.getTool(), metadata.getEdition(), version); - } Set urlCollection = metadata.getUrls(); if (urlCollection.isEmpty()) { throw new IllegalStateException("Invalid download metadata with empty urls file for " + metadata); @@ -92,9 +88,19 @@ protected Path doDownload(UrlDownloadFileMetadata metadata) { Path downloadCache = this.context.getDownloadPath().resolve(getId()); this.context.getFileAccess().mkdirs(downloadCache); Path target = downloadCache.resolve(downloadFilename); + if (Files.exists(target)) { - this.context.interaction("Artifact already exists at {}\nTo force update please delete the file and run again.", target); + // File is already cached + if (this.context.getNetworkStatus().isOffline()) { + this.context.debug("Using cached download of {} in version {} from {} (offline mode)", + metadata.getTool(), metadata.getVersion(), target); + } else { + this.context.interaction("Artifact already exists at {}\nTo force update please delete the file and run again.", target); + } } else { + if (this.context.getNetworkStatus().isOffline()) { + throw CliOfflineException.ofDownloadOfTool(metadata.getTool(), metadata.getEdition(), metadata.getVersion()); + } target = download(metadata, target); } return target; diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/InstallCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/InstallCommandletTest.java index 08b92f1177..ea9f605694 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/InstallCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/InstallCommandletTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.cli.CliOfflineException; import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; @@ -12,6 +13,8 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import static org.junit.jupiter.api.Assertions.assertThrows; + /** * Integration test of {@link InstallCommandlet}. */ @@ -202,4 +205,99 @@ public void testInstallCommandletWithSkipUpdatesInstallsWhenVersionMismatch(Wire assertThat(hasDownloadMessage).as("Should download when --skip-updates is enabled but version does not match").isTrue(); assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent("21.0.8_9"); } + + /** + * Test of {@link InstallCommandlet} when offline but tool version is already downloaded. Verifies that the tool can be installed from cached download even + * when offline. + * + * @param wmRuntimeInfo wireMock server on a random port + */ + @Test + public void testInstallCommandletOfflineWithCachedDownload(WireMockRuntimeInfo wmRuntimeInfo) { + + // arrange - install java while online to ensure it's downloaded + IdeTestContext context = newContext(PROJECT_INSTALL, wmRuntimeInfo); + InstallCommandlet install = context.getCommandletManager().getCommandlet(InstallCommandlet.class); + install.tool.setValueAsString("java", context); + String version = "17.0.6"; + install.version.setValueAsString(version, context); + install.run(); + assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(version); + + // arrange - delete the installed tool but keep the cached download + context.getFileAccess().delete(context.getSoftwarePath().resolve("java")); + context.getFileAccess().delete(context.getSoftwareRepositoryPath().resolve(DefaultToolRepository.ID_DEFAULT).resolve("java")); + + // arrange - simulate offline mode + context.getNetworkStatus().simulateNetworkError(); + + // act - try to install again while offline + install = context.getCommandletManager().getCommandlet(InstallCommandlet.class); + install.tool.setValueAsString("java", context); + install.version.setValueAsString(version, context); + install.run(); + + // assert - should successfully install from cached download + assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(version); + assertThat(context).logAtDebug().hasMessage("Using cached download of java in version " + version + " from " + + context.getDownloadPath().resolve("default").resolve("java-" + version + "-windows-x64.zip") + " (offline mode)"); + } + + /** + * Test of {@link InstallCommandlet} when offline and tool version is not cached. Verifies that installation fails with appropriate exception when tool is not + * available locally and we are offline. + * + * @param wmRuntimeInfo wireMock server on a random port + */ + @Test + public void testInstallCommandletOfflineWithoutCachedDownload(WireMockRuntimeInfo wmRuntimeInfo) { + + // arrange - create context without pre-installing anything + IdeTestContext context = newContext(PROJECT_INSTALL, wmRuntimeInfo); + context.getNetworkStatus().simulateNetworkError(); + InstallCommandlet install = context.getCommandletManager().getCommandlet(InstallCommandlet.class); + install.tool.setValueAsString("java", context); + String version = "17.0.6"; + install.version.setValueAsString(version, context); + + // act & assert - should fail with CliOfflineException + CliOfflineException e = assertThrows(CliOfflineException.class, install::run); + assertThat(e).hasMessageContaining("Not able to download tool java"); + assertThat(e).hasMessageContaining("because we are offline"); + } + + /** + * Test of {@link InstallCommandlet} when offline and trying to update to a version that is not cached. Verifies that IDEasy continues using the currently + * installed version instead of blocking the user. + * + * @param wmRuntimeInfo wireMock server on a random port + */ + @Test + public void testInstallCommandletOfflineUpdateWithoutCachedDownload(WireMockRuntimeInfo wmRuntimeInfo) { + + // arrange - install java 17.0.6 while online + IdeTestContext context = newContext(PROJECT_INSTALL, wmRuntimeInfo); + InstallCommandlet install = context.getCommandletManager().getCommandlet(InstallCommandlet.class); + install.tool.setValueAsString("java", context); + String installedVersion = "17.0.6"; + install.version.setValueAsString(installedVersion, context); + install.run(); + assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(installedVersion); + + // arrange - try to update to 17.0.10 while offline (version not cached) + context.getNetworkStatus().simulateNetworkError(); + install = context.getCommandletManager().getCommandlet(InstallCommandlet.class); + install.tool.setValueAsString("java", context); + String targetVersion = "17.0.10"; + install.version.setValueAsString(targetVersion, context); + + // act - try to install/update while offline + install.run(); + + // assert - should continue using the old version and log a warning + assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(installedVersion); + assertThat(context).logAtWarning().hasMessage("Cannot download java in version " + targetVersion + + " because we are offline. Continuing with already installed version " + installedVersion + "."); + } } + From b9e3186033b9c3ecc9390769850fdac1117ab60c Mon Sep 17 00:00:00 2001 From: Malte Brunnlieb Date: Thu, 8 Jan 2026 12:37:34 +0100 Subject: [PATCH 2/2] #1602: Fix platform-specific test assertion for cross-platform compatibility --- .../devonfw/tools/ide/commandlet/InstallCommandletTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/InstallCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/InstallCommandletTest.java index ea9f605694..14af9e88bc 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/InstallCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/InstallCommandletTest.java @@ -239,8 +239,11 @@ public void testInstallCommandletOfflineWithCachedDownload(WireMockRuntimeInfo w // assert - should successfully install from cached download assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(version); + // Determine the correct file extension and OS name based on the current OS + String fileExtension = context.getSystemInfo().isWindows() ? "zip" : "tgz"; + String osName = context.getSystemInfo().getOs().toString().toLowerCase(); assertThat(context).logAtDebug().hasMessage("Using cached download of java in version " + version + " from " - + context.getDownloadPath().resolve("default").resolve("java-" + version + "-windows-x64.zip") + " (offline mode)"); + + context.getDownloadPath().resolve("default").resolve("java-" + version + "-" + osName + "-x64." + fileExtension) + " (offline mode)"); } /**