diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 890d1ccfcd..a7640aaf92 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -58,3 +58,15 @@ jobs: files: | ./build/libs/*.jar continue-on-error: true + + - name: Run the mod and take screenshots + uses: modmuss50/xvfb-action@v1 + with: + run: ./gradlew runEndToEndTest --stacktrace --warning-mode=fail + + - name: Upload test screenshots + uses: actions/upload-artifact@v4 + if: always() + with: + name: Test Screenshots + path: run/screenshots diff --git a/build.gradle b/build.gradle index fe322a0ff8..819553a8b9 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,51 @@ loom { accessWidenerPath = file("src/main/resources/wurst.accesswidener") } +configurations { + productionRuntime { + extendsFrom configurations.minecraftLibraries + extendsFrom configurations.loaderLibraries + extendsFrom configurations.minecraftRuntimeLibraries + } +} + +dependencies { + productionRuntime "net.fabricmc:fabric-loader:${project.loader_version}" + productionRuntime "net.fabricmc:intermediary:${project.minecraft_version}" +} + +import net.fabricmc.loom.util.Platform +tasks.register('runEndToEndTest', JavaExec) { + dependsOn remapJar, downloadAssets + classpath.from configurations.productionRuntime + mainClass = "net.fabricmc.loader.impl.launch.knot.KnotClient" + workingDir = file("run") + + doFirst { + classpath.from loom.minecraftProvider.minecraftClientJar + workingDir.mkdirs() + + args( + "--assetIndex", loom.minecraftProvider.versionInfo.assetIndex().fabricId(loom.minecraftProvider.minecraftVersion()), + "--assetsDir", new File(loom.files.userCache, "assets").absolutePath, + "--gameDir", workingDir.absolutePath + ) + + if (Platform.CURRENT.operatingSystem.isMacOS()) { + jvmArgs("-XstartOnFirstThread") + } + + jvmArgs( + "-Dfabric.addMods=${configurations.modImplementation.find { it.name.contains('fabric-api') }.absolutePath}${File.pathSeparator}${remapJar.archiveFile.get().asFile.absolutePath}", + "-Dwurst.e2eTest", + "-Dfabric-tag-conventions-v2.missingTagTranslationWarning=fail", + "-Dfabric-tag-conventions-v1.legacyTagWarning=fail", + "-Dmixin.debug.verify=true", + "-Dmixin.debug.countInjections=true" + ) + } +} + processResources { inputs.property "version", project.version diff --git a/src/main/java/net/wurstclient/test/WurstClientTestHelper.java b/src/main/java/net/wurstclient/test/WurstClientTestHelper.java new file mode 100644 index 0000000000..fb53925c14 --- /dev/null +++ b/src/main/java/net/wurstclient/test/WurstClientTestHelper.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2014-2024 Wurst-Imperium and contributors. + * + * This source code is subject to the terms of the GNU General Public + * License, version 3. If a copy of the GPL was not distributed with this + * file, You can obtain one at: https://www.gnu.org/licenses/gpl-3.0.txt + */ +package net.wurstclient.test; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.Drawable; +import net.minecraft.client.gui.screen.TitleScreen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.resource.language.I18n; + +public enum WurstClientTestHelper +{ + ; + + public static boolean testAltManagerButton(MinecraftClient mc) + { + System.out.println("Checking AltManager button position"); + + if(!(mc.currentScreen instanceof TitleScreen)) + throw new RuntimeException("Not on the title screen"); + + ButtonWidget multiplayerButton = findButton(mc, "menu.multiplayer"); + ButtonWidget realmsButton = findButton(mc, "menu.online"); + ButtonWidget altManagerButton = findButton(mc, "Alt Manager"); + + checkButtonPosition(altManagerButton, realmsButton.getRight() + 4, + multiplayerButton.getBottom() + 4); + + return true; + } + + private static ButtonWidget findButton(MinecraftClient mc, + String translationKey) + { + String message = I18n.translate(translationKey); + + for(Drawable drawable : mc.currentScreen.drawables) + if(drawable instanceof ButtonWidget button + && button.getMessage().getString().equals(message)) + return button; + + throw new RuntimeException(message + " button could not be found"); + } + + private static void checkButtonPosition(ButtonWidget button, int expectedX, + int expectedY) + { + String buttonName = button.getMessage().getString(); + + if(button.getX() != expectedX) + throw new RuntimeException(buttonName + + " button is at the wrong X coordinate. Expected X: " + + expectedX + ", actual X: " + button.getX()); + + if(button.getY() != expectedY) + throw new RuntimeException(buttonName + + " button is at the wrong Y coordinate. Expected Y: " + + expectedY + ", actual Y: " + button.getY()); + } +} diff --git a/src/main/java/net/wurstclient/test/WurstE2ETestClient.java b/src/main/java/net/wurstclient/test/WurstE2ETestClient.java new file mode 100644 index 0000000000..7d231666ab --- /dev/null +++ b/src/main/java/net/wurstclient/test/WurstE2ETestClient.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2014-2024 Wurst-Imperium and contributors. + * + * This source code is subject to the terms of the GNU General Public + * License, version 3. If a copy of the GPL was not distributed with this + * file, You can obtain one at: https://www.gnu.org/licenses/gpl-3.0.txt + */ +package net.wurstclient.test; + +import static net.wurstclient.test.fabric.FabricClientTestHelper.*; + +import java.time.Duration; + +import org.spongepowered.asm.mixin.MixinEnvironment; + +import net.fabricmc.api.ModInitializer; +import net.minecraft.client.gui.screen.AccessibilityOnboardingScreen; +import net.minecraft.client.gui.screen.TitleScreen; +import net.minecraft.client.gui.screen.world.CreateWorldScreen; +import net.minecraft.client.gui.screen.world.SelectWorldScreen; + +// It would be cleaner to have this test in src/test/java, but remapping that +// into a separate testmod is a whole can of worms. +public final class WurstE2ETestClient implements ModInitializer +{ + @Override + public void onInitialize() + { + if(System.getProperty("wurst.e2eTest") == null) + return; + + Thread.ofVirtual().name("Wurst End-to-End Test") + .uncaughtExceptionHandler((t, e) -> { + e.printStackTrace(); + System.exit(1); + }).start(this::runTest); + } + + private void runTest() + { + System.out.println("Starting Wurst End-to-End Test"); + waitForLoadingComplete(); + + if(submitAndWait(mc -> mc.options.onboardAccessibility)) + { + System.out.println("Onboarding is enabled. Waiting for it"); + waitForScreen(AccessibilityOnboardingScreen.class); + System.out.println("Reached onboarding screen"); + clickScreenButton("gui.continue"); + } + + waitForScreen(TitleScreen.class); + waitForTitleScreenFade(); + System.out.println("Reached title screen"); + takeScreenshot("title_screen", Duration.ZERO); + + submitAndWait(WurstClientTestHelper::testAltManagerButton); + // TODO: Test more of AltManager + + System.out.println("Clicking singleplayer button"); + clickScreenButton("menu.singleplayer"); + + if(submitAndWait(mc -> !mc.getLevelStorage().getLevelList().isEmpty())) + { + System.out.println("World list is not empty. Waiting for it"); + waitForScreen(SelectWorldScreen.class); + System.out.println("Reached select world screen"); + takeScreenshot("select_world_screen"); + clickScreenButton("selectWorld.create"); + } + + waitForScreen(CreateWorldScreen.class); + System.out.println("Reached create world screen"); + + // Select creative mode + clickScreenButton("selectWorld.gameMode"); + clickScreenButton("selectWorld.gameMode"); + takeScreenshot("create_world_screen"); + + System.out.println("Creating test world"); + clickScreenButton("selectWorld.create"); + + waitForWorldTicks(200); + System.out.println("Reached singleplayer world"); + takeScreenshot("in_game", Duration.ZERO); + + System.out.println("Opening debug menu"); + enableDebugHud(); + takeScreenshot("debug_menu"); + + System.out.println("Closing debug menu"); + enableDebugHud();// bad name, it actually toggles + + System.out.println("Checking for broken mixins"); + MixinEnvironment.getCurrentEnvironment().audit(); + + // TODO: Test some Wurst hacks + + System.out.println("Opening inventory"); + openInventory(); + takeScreenshot("inventory"); + + System.out.println("Closing inventory"); + closeScreen(); + + // TODO: Open ClickGUI and Navigator + + System.out.println("Opening game menu"); + openGameMenu(); + takeScreenshot("game_menu"); + + // TODO: Check Wurst Options + + System.out.println("Returning to title screen"); + clickScreenButton("menu.returnToMenu"); + waitForScreen(TitleScreen.class); + + System.out.println("Stopping the game"); + clickScreenButton("menu.quit"); + } +} diff --git a/src/main/java/net/wurstclient/test/fabric/FabricClientTestHelper.java b/src/main/java/net/wurstclient/test/fabric/FabricClientTestHelper.java new file mode 100644 index 0000000000..c6e0ecf043 --- /dev/null +++ b/src/main/java/net/wurstclient/test/fabric/FabricClientTestHelper.java @@ -0,0 +1,256 @@ +/* + * Copied from https://github.com/FabricMC/fabric/blob/ + * f17fc976e9d0d6fe6fc7303fb25ca7b24d122c98/fabric-api-base/src/testmodClient/ + * java/net/fabricmc/fabric/test/base/client/FabricClientTestHelper.java + * and formatted to match our code style. + * + * This isn't a part of Fabric's public API (yet), so for now the only way to + * use it is to make a copy. + */ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.wurstclient.test.fabric; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.function.Predicate; + +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.Drawable; +import net.minecraft.client.gui.screen.GameMenuScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.TitleScreen; +import net.minecraft.client.gui.screen.ingame.CreativeInventoryScreen; +import net.minecraft.client.gui.screen.ingame.InventoryScreen; +import net.minecraft.client.gui.screen.world.LevelLoadingScreen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.gui.widget.CyclingButtonWidget; +import net.minecraft.client.gui.widget.PressableWidget; +import net.minecraft.client.gui.widget.Widget; +import net.minecraft.client.option.Perspective; +import net.minecraft.client.util.ScreenshotRecorder; +import net.minecraft.text.Text; + +// Provides thread safe utils for interacting with a running game. +public final class FabricClientTestHelper +{ + public static void waitForLoadingComplete() + { + waitFor("Loading to complete", client -> client.getOverlay() == null, + Duration.ofMinutes(5)); + } + + public static void waitForScreen(Class screenClass) + { + waitFor("Screen %s".formatted(screenClass.getName()), + client -> client.currentScreen != null + && client.currentScreen.getClass() == screenClass); + } + + public static void openGameMenu() + { + setScreen(client -> new GameMenuScreen(true)); + waitForScreen(GameMenuScreen.class); + } + + public static void openInventory() + { + setScreen(client -> new InventoryScreen( + Objects.requireNonNull(client.player))); + + boolean creative = submitAndWait( + client -> Objects.requireNonNull(client.player).isCreative()); + waitForScreen( + creative ? CreativeInventoryScreen.class : InventoryScreen.class); + } + + public static void closeScreen() + { + setScreen(client -> null); + } + + private static void setScreen( + Function screenSupplier) + { + submit(client -> { + client.setScreen(screenSupplier.apply(client)); + return null; + }); + } + + public static void takeScreenshot(String name) + { + takeScreenshot(name, Duration.ofMillis(50)); + } + + public static void takeScreenshot(String name, Duration delay) + { + // Allow time for any screens to open + waitFor(delay); + + submitAndWait(client -> { + ScreenshotRecorder.saveScreenshot( + FabricLoader.getInstance().getGameDir().toFile(), name + ".png", + client.getFramebuffer(), message -> {}); + return null; + }); + } + + public static void clickScreenButton(String translationKey) + { + final String buttonText = Text.translatable(translationKey).getString(); + + waitFor("Click button" + buttonText, client -> { + final Screen screen = client.currentScreen; + + if(screen == null) + return false; + + // Replaced the accessor with an access widener + for(Drawable drawable : screen.drawables) + { + if(drawable instanceof PressableWidget pressableWidget + && pressMatchingButton(pressableWidget, buttonText)) + return true; + + if(drawable instanceof Widget widget) + widget.forEachChild(clickableWidget -> pressMatchingButton( + clickableWidget, buttonText)); + } + + // Was unable to find the button to press + return false; + }); + } + + private static boolean pressMatchingButton(ClickableWidget widget, + String text) + { + if(widget instanceof ButtonWidget buttonWidget + && text.equals(buttonWidget.getMessage().getString())) + { + buttonWidget.onPress(); + return true; + } + + // Replaced the accessor with an access widener + if(widget instanceof CyclingButtonWidget buttonWidget + && text.equals(buttonWidget.optionText.getString())) + { + buttonWidget.onPress(); + return true; + } + + return false; + } + + public static void waitForWorldTicks(long ticks) + { + // Wait for the world to be loaded and get the start ticks + waitFor("World load", + client -> client.world != null + && !(client.currentScreen instanceof LevelLoadingScreen), + Duration.ofMinutes(30)); + final long startTicks = submitAndWait(client -> client.world.getTime()); + waitFor("World load", client -> Objects.requireNonNull(client.world) + .getTime() > startTicks + ticks, Duration.ofMinutes(10)); + } + + public static void enableDebugHud() + { + submitAndWait(client -> { + client.inGameHud.getDebugHud().toggleDebugHud(); + return null; + }); + } + + public static void setPerspective(Perspective perspective) + { + submitAndWait(client -> { + client.options.setPerspective(perspective); + return null; + }); + } + + // Removed connectToServer() + + public static void waitForTitleScreenFade() + { + waitFor("Title screen fade", client -> { + if(!(client.currentScreen instanceof TitleScreen titleScreen)) + return false; + + // Replaced the accessor with an access widener + return !titleScreen.doBackgroundFade; + }); + } + + private static void waitFor(String what, + Predicate predicate) + { + waitFor(what, predicate, Duration.ofSeconds(10)); + } + + private static void waitFor(String what, + Predicate predicate, Duration timeout) + { + final LocalDateTime end = LocalDateTime.now().plus(timeout); + + while(true) + { + boolean result = submitAndWait(predicate::test); + + if(result) + break; + + if(LocalDateTime.now().isAfter(end)) + throw new RuntimeException("Timed out waiting for " + what); + + waitFor(Duration.ofMillis(50)); + } + } + + private static void waitFor(Duration duration) + { + try + { + Thread.sleep(duration.toMillis()); + + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("resource") + private static CompletableFuture submit( + Function function) + { + return MinecraftClient.getInstance() + .submit(() -> function.apply(MinecraftClient.getInstance())); + } + + public static T submitAndWait(Function function) + { + return submit(function).join(); + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index b6595d15fc..798fcd9972 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -17,7 +17,8 @@ "environment": "client", "entrypoints": { "main": [ - "net.wurstclient.WurstInitializer" + "net.wurstclient.WurstInitializer", + "net.wurstclient.test.WurstE2ETestClient" ] }, "mixins": [ diff --git a/src/main/resources/wurst.accesswidener b/src/main/resources/wurst.accesswidener index 14b3413edb..f52ebebc61 100644 --- a/src/main/resources/wurst.accesswidener +++ b/src/main/resources/wurst.accesswidener @@ -9,8 +9,10 @@ accessible method net/minecraft/entity/projectile/FishingBobberEntity isOpenOrWa accessible field net/minecraft/client/MinecraftClient itemUseCooldown I accessible field net/minecraft/client/gui/hud/ChatHud visibleMessages Ljava/util/List; accessible field net/minecraft/client/gui/screen/Screen drawables Ljava/util/List; +accessible field net/minecraft/client/gui/screen/TitleScreen doBackgroundFade Z accessible field net/minecraft/client/gui/screen/ingame/CreativeInventoryScreen selectedTab Lnet/minecraft/item/ItemGroup; accessible field net/minecraft/client/gui/screen/option/ControlsListWidget$KeyBindingEntry bindingName Lnet/minecraft/text/Text; +accessible field net/minecraft/client/gui/widget/CyclingButtonWidget optionText Lnet/minecraft/text/Text; accessible field net/minecraft/client/network/ClientPlayNetworkHandler messagePacker Lnet/minecraft/network/message/MessageChain$Packer; accessible field net/minecraft/client/network/ClientPlayNetworkHandler session Lnet/minecraft/network/encryption/ClientPlayerSession; accessible field net/minecraft/client/network/ClientPlayerEntity lastPitch F