From 24702dc5a0214034f4c27166d5fd30cad08cec19 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 10 Sep 2026 10:14:11 +0800 Subject: [PATCH] Fix #6833: Automatically synchronize client JARs when updating game instances (#6843) --- .../UpdateInstallerWizardProvider.java | 3 +- .../hmcl/ui/instances/InstallerListPage.java | 10 +- .../download/DefaultDependencyManager.java | 65 ++++++ .../hmcl/game/DefaultGameRepository.java | 52 ----- .../game/DefaultGameRepositoryDraftTest.java | 221 +++++++++++++++++- 5 files changed, 289 insertions(+), 62 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java index 562088d58c..23a6612c03 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java @@ -65,6 +65,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider { public void start(SettingsMap settings) { } + /// Creates a task that updates components and synchronizes the client JAR with the resulting manifest. @Override public Object finish(SettingsMap settings) { settings.put("title", i18n("install.change_version.process")); @@ -83,7 +84,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider { } } - return gameInstance.getRepository().updateInstanceAsync(gameInstance.getId(), publishedInstance -> { + return dependencyManager.updateInstanceAsync(gameInstance.getId(), publishedInstance -> { Task update = Task.supplyAsync(publishedInstance::getManifest); for (Object value : settings.asStringMap().values()) { if (value instanceof ComponentRemoteVersion remoteVersion) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java index 43f63e0716..2935343558 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java @@ -66,8 +66,9 @@ public class InstallerListPage extends ListPageBase { return new InstallerListPageSkin(); } + /// Rebuilds the component controls for the selected instance, or clears them if none is selected. public void loadInstance(HMCLGameInstance.Optional instance) { - HMCLGameInstance gameInstance = instance.instance(); + @Nullable HMCLGameInstance gameInstance = instance.instance(); this.gameInstance = gameInstance; if (gameInstance == null) { itemsProperty().clear(); @@ -103,7 +104,7 @@ public class InstallerListPage extends ListPageBase { Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType(), libraryVersion)); }); - component.setOnRemove(() -> repository.updateInstanceAsync( + component.setOnRemove(() -> repository.getDependency().updateInstanceAsync( gameInstance.getId(), publishedInstance -> repository.getDependency().removeComponentAsync( publishedInstance, @@ -123,7 +124,7 @@ public class InstallerListPage extends ListPageBase { InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM); installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false)); - installerItem.setOnRemove(() -> repository.updateInstanceAsync( + installerItem.setOnRemove(() -> repository.getDependency().updateInstanceAsync( gameInstance.getId(), publishedInstance -> repository.getDependency().removeComponentAsync( publishedInstance, @@ -148,13 +149,14 @@ public class InstallerListPage extends ListPageBase { if (file != null) doInstallOffline(file); } + /// Runs a local component installer for the current modifiable instance and displays its result. private void doInstallOffline(Path file) { if (gameInstance == null || !gameInstance.getManifest().isModifiable()) { return; } HMCLGameRepository repository = gameInstance.getRepository(); - Task task = repository.updateInstanceAsync( + Task task = repository.getDependency().updateInstanceAsync( gameInstance.getId(), publishedInstance -> repository.getDependency().installComponentLocalAsync(publishedInstance, file)); task.setName(i18n("install.installer.install_offline")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java index 93f029b803..6e8cb8bc79 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -26,6 +26,7 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask; import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.function.ExceptionalFunction; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; @@ -36,6 +37,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.regex.Matcher; /// Provides downloads and game-component installation for one game repository. @@ -163,6 +165,69 @@ public class DefaultDependencyManager extends AbstractDependencyManager { } } + /// Creates a task that updates an instance's manifest and its own client JAR in one draft. + /// + /// The updater receives the published instance and must return a manifest with the same id. + /// If the updated manifest uses this instance's own JAR, that JAR is replaced when the resolved + /// client download changes or the previous manifest used another instance's JAR. Downloads are + /// compared by SHA-1 when either declares a checksum, otherwise by URL and size. Matching + /// checksums identify the same client even when the URLs differ. Referenced JARs are not modified. + /// + /// A replacement is obtained through this manager's download provider and cache before the + /// manifest and JAR are committed together. A task failure aborts the draft and retains the + /// previously published instance files. Shared cache files written by the tasks are retained. + /// + /// @param the checked exception type thrown while creating the update task + /// @param instanceId the instance to update + /// @param updater the asynchronous manifest update + /// @return the task that commits the updated instance + public Task updateInstanceAsync( + GameInstanceID instanceId, + ExceptionalFunction, E> updater) { + return Task.composeAsync(() -> { + DefaultGameRepositoryDraft draft = repository.openDraft(); + try { + GameInstance instance = draft.getBaseSnapshot().getInstance(instanceId); + return Objects.requireNonNull(updater.apply(instance), "Instance updater returned null") + .thenComposeAsync(manifest -> { + if (!instanceId.equals(manifest.id())) { + throw new IllegalArgumentException( + "Instance updater changed id from " + instanceId + " to " + manifest.id()); + } + GameInstanceManifest previous = instance.getResolvedManifest(); + GameInstanceManifest resolved = draft.getBaseSnapshot().resolve(manifest); + draft.put(manifest); + if (instanceId.equals(resolved.jar()) + && (!instanceId.equals(previous.jar()) + || !isSameClientDownload(previous.getDownloadInfo(), resolved.getDownloadInfo()))) { + return new GameDownloadTask(this, resolved).thenAcceptAsync( + jar -> draft.putPrimaryJar(instanceId, jar)); + } + return null; + }).thenRunAsync(() -> { + draft.commit(); + }).whenComplete(exception -> { + if (draft.isOpen()) { + draft.abort(); + } + }); + } catch (Throwable exception) { + abortDraftAfterFailure(draft, exception); + throw exception; + } + }); + } + + /// Compares client identity by checksum, falling back to URL and size when neither has one. + private static boolean isSameClientDownload(DownloadInfo previous, DownloadInfo updated) { + @Nullable String previousSha1 = previous.getSha1(); + @Nullable String updatedSha1 = updated.getSha1(); + if (previousSha1 != null || updatedSha1 != null) { + return previousSha1 != null && previousSha1.equalsIgnoreCase(updatedSha1); + } + return previous.getUrl().equals(updated.getUrl()) && previous.getSize() == updated.getSize(); + } + @Override public Task checkGameCompletionAsync( GameInstance instance, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index c441b7d9fc..6725d4e318 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -24,7 +24,6 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.function.ExceptionalFunction; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNullByDefault; @@ -525,57 +524,6 @@ public abstract class DefaultGameRepository implements GameRepository { return Task.supplyAsync(() -> save(instanceManifest)); } - /// Creates a task that updates one registered instance inside an exclusive draft. - /// - /// The updater receives the instance from the immutable published snapshot and must return a - /// working manifest with the same id. Its result is staged and committed exactly once. Failure - /// or cancellation aborts the draft; shared cache files written by the updater are retained. - /// - /// @param the checked exception type thrown while creating the update task - /// @param instanceId the instance to update - /// @param updater the asynchronous manifest update - /// @return the task that commits the updated manifest - public Task updateInstanceAsync( - GameInstanceID instanceId, - ExceptionalFunction, E> updater) { - return Task.composeAsync(() -> { - DefaultGameRepositoryDraft draft = openDraft(); - try { - return Objects.requireNonNull( - updater.apply(draft.getBaseSnapshot().getInstance(instanceId)), - "Instance updater returned null") - .thenApplyAsync(manifest -> { - if (!instanceId.equals(manifest.id())) { - throw new IllegalArgumentException( - "Instance updater changed id from " + instanceId + " to " + manifest.id()); - } - draft.put(manifest); - draft.commit(); - return manifest; - }).whenComplete(exception -> { - if (draft.isOpen()) { - draft.abort(); - } - }); - } catch (Throwable exception) { - abortDraftAfterFailure(draft, exception); - throw exception; - } - }); - } - - /// Aborts a draft after task construction fails and preserves any cleanup failure. - /// - /// @param draft the draft to abort - /// @param failure the failure that prevented task construction - private static void abortDraftAfterFailure(DefaultGameRepositoryDraft draft, Throwable failure) { - try { - draft.abort(); - } catch (Exception cleanupFailure) { - failure.addSuppressed(cleanupFailure); - } - } - /// Creates an empty unsealed snapshot for the given layout. /// /// @param layout the layout for the new snapshot diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java index 1b156b2ccc..cdda05ba24 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java @@ -17,17 +17,29 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.download.DefaultCacheRepository; +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.MojangDownloadProvider; +import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.UrlResponseInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpHeaders; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -277,18 +289,211 @@ public final class DefaultGameRepositoryDraftTest { assertEquals(GameRepositoryDraft.State.FAILED, draft.getState()); } - /// Runs an asynchronous update using the published instance as immutable context. + /// A manifest-only update preserves an existing client JAR. @Test public void testUpdateInstanceAsyncCommitsWorkingManifest(@TempDir Path tempDirectory) throws Exception { TestRepository repository = new TestRepository(tempDirectory); GameInstanceID id = new GameInstanceID("instance"); repository.save(new GameInstanceManifest(id).withMainClass("original.Main")); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "custom client"); - Task update = repository.updateInstanceAsync(id, workingInstance -> Task.supplyAsync(() -> + Task update = createDependencyManager(repository).updateInstanceAsync(id, workingInstance -> Task.supplyAsync(() -> workingInstance.getManifest().withMainClass("updated.Main"))); assertTrue(update.executor().test()); assertEquals("updated.Main", repository.getInstance(id).getManifest().mainClass()); + assertEquals("custom client", Files.readString(target)); + } + + /// A version change replaces an existing client JAR with the file selected by the new manifest. + @Test + public void testUpdateInstanceAsyncReplacesClientJar(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + repository.save(original); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "old client"); + Path source = tempDirectory.resolve("new-client.jar"); + Files.writeString(source, "new client"); + String sha1 = DigestUtils.digestToString("SHA-1", source); + DefaultCacheRepository cache = new DefaultCacheRepository(tempDirectory.resolve("cache")); + Path cached = cache.cacheFile(source, "SHA-1", sha1); + DefaultDependencyManager manager = new DefaultDependencyManager( + repository, new MojangDownloadProvider(), cache); + GameInstanceManifest replacement = original.withDownloads(Map.of(DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/client.jar", sha1))); + + Task update = manager.updateInstanceAsync(id, instance -> + new GameDownloadTask(manager, replacement).thenApplyAsync(jar -> { + assertEquals("old client", Files.readString(target)); + assertEquals(original, repository.getInstance(id).getManifest()); + return replacement; + })); + assertTrue(update.executor().test()); + + assertEquals(replacement, repository.getInstance(id).getManifest()); + assertTrue(replacement.getDownloadInfo().validateChecksum(target, false)); + assertEquals("new client", Files.readString(cached)); + } + + /// Equivalent checksums preserve a customized client even when the download URL changes. + @Test + public void testUpdateInstanceAsyncPreservesClientWithSameChecksum(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withDownloads(Map.of(DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/old.jar", "a".repeat(40)))); + repository.save(original); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "custom client"); + GameInstanceManifest replacement = original.withDownloads(Map.of(DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/new.jar", "A".repeat(40)))); + + assertTrue(createDependencyManager(repository).updateInstanceAsync(id, + instance -> Task.completed(replacement)).executor().test()); + + assertEquals(replacement, repository.getInstance(id).getManifest()); + assertEquals("custom client", Files.readString(target)); + } + + /// A changed URL selects a replacement when neither client download declares a checksum. + @Test + public void testUpdateInstanceAsyncReplacesClientWithoutChecksum(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withDownloads(Map.of(DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/old.jar"))); + repository.save(original); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "old client"); + String newUrl = "https://example.invalid/new.jar"; + DefaultDependencyManager manager = createDependencyManager(repository); + manager.getCacheRepository().cacheText(new UrlResponseInfo(200, URI.create(newUrl), + HttpHeaders.of(Map.of("etag", List.of("new-client"), + "cache-control", List.of("max-age=3600")), (name, value) -> true)), "new client"); + GameInstanceManifest replacement = original.withDownloads(Map.of(DownloadType.CLIENT, new DownloadInfo(newUrl))); + + assertTrue(manager.updateInstanceAsync(id, instance -> Task.completed(replacement)).executor().test()); + + assertEquals(replacement, repository.getInstance(id).getManifest()); + assertEquals("new client", Files.readString(target)); + } + + /// Client changes supplied only by patches are resolved before choosing the replacement JAR. + @Test + public void testUpdateInstanceAsyncResolvesClientPatch(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id); + repository.save(original); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "old client"); + Path source = tempDirectory.resolve("new-client.jar"); + Files.writeString(source, "new client"); + String sha1 = DigestUtils.digestToString("SHA-1", source); + DefaultDependencyManager manager = createDependencyManager(repository); + manager.getCacheRepository().cacheFile(source, "SHA-1", sha1); + GameInstanceManifest replacement = original.withPatches(List.of(GameInstancePatch.fromManifest( + original.withDownloads(Map.of(DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/client.jar", sha1))), + GameComponentType.GAME.getPatchId(), "new-version", GameInstancePatch.PRIORITY_MC))); + + assertTrue(manager.updateInstanceAsync(id, instance -> Task.completed(replacement)).executor().test()); + + assertEquals(replacement, repository.getInstance(id).getManifest()); + assertEquals("new client", Files.readString(target)); + } + + /// Switching JAR ownership materializes an owned client but never replaces a referenced client. + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testUpdateInstanceAsyncSwitchesJarOwnership(boolean useOwnJar, @TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceID sharedId = new GameInstanceID("shared"); + repository.save(new GameInstanceManifest(sharedId)); + Path sharedJar = repository.getLayout().getInstanceJarFile(sharedId); + Files.writeString(sharedJar, "shared client"); + String sha1 = DigestUtils.digestToString("SHA-1", sharedJar); + DownloadInfo download = new DownloadInfo("https://example.invalid/client.jar", sha1); + GameInstanceManifest original = new GameInstanceManifest(id) + .withJar(useOwnJar ? sharedId : id) + .withDownloads(useOwnJar ? Map.of(DownloadType.CLIENT, download) : null); + repository.save(original); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "custom client"); + DefaultDependencyManager manager = createDependencyManager(repository); + manager.getCacheRepository().cacheFile(sharedJar, "SHA-1", sha1); + GameInstanceManifest replacement = original.withJar(useOwnJar ? id : sharedId) + .withDownloads(Map.of(DownloadType.CLIENT, download)); + + assertTrue(manager.updateInstanceAsync(id, instance -> Task.completed(replacement)).executor().test()); + + assertEquals(useOwnJar ? "shared client" : "custom client", Files.readString(target)); + assertEquals("shared client", Files.readString(sharedJar)); + assertEquals(useOwnJar ? target : sharedJar, repository.getInstance(id).getInstanceJarFile()); + } + + /// A failed JAR lookup leaves both the old manifest and its client JAR intact. + @Test + public void testUpdateInstanceAsyncPreservesFilesWhenJarTaskFails(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + repository.save(original); + Path manifestFile = repository.getLayout().getInstanceJson(id); + String originalJson = Files.readString(manifestFile); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "original"); + + GameInstanceManifest replacement = original.withDownloads(Map.of(DownloadType.CLIENT, + new DownloadInfo("file:///missing-client.jar", "0".repeat(40)))); + Task update = createDependencyManager(repository).updateInstanceAsync(id, + instance -> Task.completed(replacement)); + assertFalse(update.executor().test()); + + assertEquals(original, repository.getInstance(id).getManifest()); + assertEquals(originalJson, Files.readString(manifestFile)); + assertEquals("original", Files.readString(target)); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertTrue(draft.isOpen()); + } + } + + /// Publication failure rolls back both files after an asynchronous update stages a new JAR. + @Test + public void testUpdateInstanceAsyncRollsBackManifestAndJar(@TempDir Path tempDirectory) throws Exception { + FailingRepository repository = new FailingRepository(tempDirectory.resolve("game")); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + repository.save(original); + Path manifestFile = repository.getLayout().getInstanceJson(id); + String originalJson = Files.readString(manifestFile); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "original"); + Path source = tempDirectory.resolve("replacement.jar"); + Files.writeString(source, "replacement"); + repository.failDraftPublish = true; + + DefaultDependencyManager manager = createDependencyManager(repository); + String sha1 = DigestUtils.digestToString("SHA-1", source); + Path cached = manager.getCacheRepository().cacheFile(source, "SHA-1", sha1); + GameInstanceManifest replacement = original.withMainClass("updated.Main") + .withDownloads(Map.of(DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/client.jar", sha1))); + Task update = manager.updateInstanceAsync(id, instance -> Task.completed(replacement)); + assertFalse(update.executor().test()); + + assertEquals(original, repository.getInstance(id).getManifest()); + assertEquals(originalJson, Files.readString(manifestFile)); + assertEquals("original", Files.readString(target)); + assertEquals("replacement", Files.readString(source)); + assertEquals("replacement", Files.readString(cached)); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertTrue(draft.isOpen()); + } } /// Rejects an asynchronous update that attempts to create a different instance id. @@ -299,7 +504,7 @@ public final class DefaultGameRepositoryDraftTest { GameInstanceID otherId = new GameInstanceID("other"); repository.save(new GameInstanceManifest(id).withMainClass("original.Main")); - Task update = repository.updateInstanceAsync(id, workingInstance -> + Task update = createDependencyManager(repository).updateInstanceAsync(id, workingInstance -> Task.supplyAsync(() -> workingInstance.getManifest().withId(otherId))); assertFalse(update.executor().test()); @@ -316,7 +521,7 @@ public final class DefaultGameRepositoryDraftTest { TestRepository repository = new TestRepository(tempDirectory); GameInstanceID id = new GameInstanceID("missing"); - Task update = repository.updateInstanceAsync(id, workingInstance -> + Task update = createDependencyManager(repository).updateInstanceAsync(id, workingInstance -> Task.completed(workingInstance.getManifest())); assertFalse(update.executor().test()); @@ -332,7 +537,7 @@ public final class DefaultGameRepositoryDraftTest { GameInstanceID id = new GameInstanceID("instance"); repository.save(new GameInstanceManifest(id)); - Task update = repository.updateInstanceAsync(id, workingInstance -> { + Task update = createDependencyManager(repository).updateInstanceAsync(id, workingInstance -> { throw new IOException("Simulated updater failure"); }); assertFalse(update.executor().test()); @@ -342,6 +547,12 @@ public final class DefaultGameRepositoryDraftTest { } } + /// Creates a dependency manager with a cache isolated inside the test repository. + private static DefaultDependencyManager createDependencyManager(TestRepository repository) { + return new DefaultDependencyManager(repository, new MojangDownloadProvider(), + new DefaultCacheRepository(repository.getBaseDirectory().resolve("cache"))); + } + /// Minimal repository implementation for draft tests. @NotNullByDefault private static class TestRepository extends DefaultGameRepository {