+ * For non-translated buttons, the translationKey parameter should be the + * raw button text instead. + */ + public 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"); + } + + /** + * Looks for the given button at the given coordinates and fails if it is + * not there. + */ + public 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()); + } + + /** + * Clicks the button with the given translation key, or fails after 10 + * seconds. + * + *
+ * For non-translated buttons, the translationKey parameter should be the + * raw button text instead. + */ + public static void clickButton(String translationKey) + { + String buttonText = I18n.translate(translationKey); + + waitUntil("button saying " + buttonText + " is visible", mc -> { + Screen screen = mc.currentScreen; + if(screen == null) + return false; + + for(Drawable drawable : screen.drawables) + { + if(!(drawable instanceof ClickableWidget widget)) + continue; + + if(widget instanceof ButtonWidget button + && buttonText.equals(button.getMessage().getString())) + { + button.onPress(); + return true; + } + + if(widget instanceof CyclingButtonWidget> button + && buttonText.equals(button.optionText.getString())) + { + button.onPress(); + return true; + } + } + + return false; + }); + } + + /** + * Types the given text into the nth text field on the current screen, or + * fails after 10 seconds. + */ + public static void setTextFieldText(int index, String text) + { + waitUntil("text field #" + index + " is visible", mc -> { + Screen screen = mc.currentScreen; + if(screen == null) + return false; + + int i = 0; + for(Drawable drawable : screen.drawables) + { + if(!(drawable instanceof TextFieldWidget textField)) + continue; + + if(i == index) + { + textField.setText(text); + return true; + } + + i++; + } + + return false; + }); + } + + public static void closeScreen() + { + submitAndWait(mc -> mc.setScreen(null)); + } + + public static void openGameMenu() + { + submitAndWait(mc -> mc.setScreen(new GameMenuScreen(true))); + } + + public static void openInventory() + { + submitAndWait(mc -> mc.setScreen(new InventoryScreen(mc.player))); + } + + public static void toggleDebugHud() + { + submitAndWait(mc -> mc.inGameHud.getDebugHud().toggleDebugHud()); + } + + public static void setPerspective(Perspective perspective) + { + submitAndWait(mc -> mc.options.setPerspective(perspective)); + } + + public static void dismissTutorialToasts() + { + submitAndWait(mc -> mc.getTutorialManager().setStep(TutorialStep.NONE)); + } + + public static void clearChat() + { + submitAndWait(mc -> mc.inGameHud.getChatHud().clear(true)); + } + + /** + * Runs the given chat command and waits one tick for the action to + * complete. + * + *
+ * Do not put a / at the start of the command. + */ + public static void runChatCommand(String command) + { + System.out.println("Running command: /" + command); + submitAndWait(mc -> { + ClientPlayNetworkHandler netHandler = mc.getNetworkHandler(); + + // Validate command using client-side command dispatcher + ParseResults> results = netHandler.getCommandDispatcher() + .parse(command, netHandler.getCommandSource()); + + // Command is invalid, fail the test + if(!results.getExceptions().isEmpty()) + { + StringBuilder errors = + new StringBuilder("Invalid command: " + command); + for(CommandSyntaxException e : results.getExceptions().values()) + errors.append("\n").append(e.getMessage()); + + throw new RuntimeException(errors.toString()); + } + + // Command is valid, send it + netHandler.sendChatCommand(command); + }); + waitForWorldTicks(1); + } + + public static void assertOneItemInSlot(int slot, Item item) + { + submitAndWait(mc -> { + ItemStack stack = mc.player.getInventory().getStack(slot); + if(!stack.isOf(item) || stack.getCount() != 1) + throw new RuntimeException( + "Expected 1 " + item.getName().getString() + " at slot " + + slot + ", found " + stack.getCount() + " " + + stack.getItem().getName().getString() + " instead"); + }); + } + + public static void assertNoItemInSlot(int slot) + { + submitAndWait(mc -> { + ItemStack stack = mc.player.getInventory().getStack(slot); + if(!stack.isEmpty()) + throw new RuntimeException("Expected no item in slot " + slot + + ", found " + stack.getCount() + " " + + stack.getItem().getName().getString() + " instead"); + }); + } +} diff --git a/src/main/java/net/wurstclient/zoom/test/WiZoomTestClient.java b/src/main/java/net/wurstclient/zoom/test/WiZoomTestClient.java new file mode 100644 index 0000000..f961def --- /dev/null +++ b/src/main/java/net/wurstclient/zoom/test/WiZoomTestClient.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2019-2025 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.zoom.test; + +import static net.wurstclient.zoom.test.WiModsTestHelper.*; + +import java.time.Duration; + +import org.spongepowered.asm.mixin.MixinEnvironment; + +import net.fabricmc.api.ClientModInitializer; +import net.minecraft.SharedConstants; +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; + +public final class WiZoomTestClient implements ClientModInitializer +{ + @Override + public void onInitializeClient() + { + if(System.getProperty("wi_zoom.e2eTest") == null) + return; + + Thread.ofVirtual().name("WI Zoom End-to-End Test") + .uncaughtExceptionHandler((t, e) -> { + e.printStackTrace(); + System.exit(1); + }).start(this::runTests); + } + + private void runTests() + { + System.out.println("Starting WI Zoom End-to-End Test"); + waitForResourceLoading(); + + if(submitAndGet(mc -> mc.options.onboardAccessibility)) + { + System.out.println("Onboarding is enabled. Waiting for it"); + waitForScreen(AccessibilityOnboardingScreen.class); + System.out.println("Reached onboarding screen"); + clickButton("gui.continue"); + } + + waitForScreen(TitleScreen.class); + waitForTitleScreenFade(); + System.out.println("Reached title screen"); + takeScreenshot("title_screen", Duration.ZERO); + + System.out.println("Clicking singleplayer button"); + clickButton("menu.singleplayer"); + + if(submitAndGet(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"); + clickButton("selectWorld.create"); + } + + waitForScreen(CreateWorldScreen.class); + System.out.println("Reached create world screen"); + + // Set MC version as world name + setTextFieldText(0, + "E2E Test " + SharedConstants.getGameVersion().getName()); + // Select creative mode + clickButton("selectWorld.gameMode"); + clickButton("selectWorld.gameMode"); + takeScreenshot("create_world_screen"); + + System.out.println("Creating test world"); + clickButton("selectWorld.create"); + + waitForWorldLoad(); + dismissTutorialToasts(); + waitForWorldTicks(200); + runChatCommand("seed"); + System.out.println("Reached singleplayer world"); + takeScreenshot("in_game", Duration.ZERO); + clearChat(); + + System.out.println("Opening debug menu"); + toggleDebugHud(); + takeScreenshot("debug_menu"); + + System.out.println("Closing debug menu"); + toggleDebugHud(); + + System.out.println("Checking for broken mixins"); + MixinEnvironment.getCurrentEnvironment().audit(); + + System.out.println("Opening inventory"); + openInventory(); + takeScreenshot("inventory"); + + System.out.println("Closing inventory"); + closeScreen(); + + // Build a test platform and clear out the space above it + runChatCommand("fill ~-5 ~-1 ~-5 ~5 ~-1 ~5 stone"); + runChatCommand("fill ~-5 ~ ~-5 ~5 ~30 ~5 air"); + + // Clear inventory and chat before running tests + runChatCommand("clear"); + clearChat(); + + // TODO: Add WI Zoom-specific test code here + + System.out.println("Opening game menu"); + openGameMenu(); + takeScreenshot("game_menu"); + + System.out.println("Returning to title screen"); + clickButton("menu.returnToMenu"); + waitForScreen(TitleScreen.class); + + System.out.println("Stopping the game"); + clickButton("menu.quit"); + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index a6560b1..63fa649 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -20,11 +20,15 @@ "entrypoints": { "main": [ "net.wurstclient.zoom.WiZoomInitializer" + ], + "client": [ + "net.wurstclient.zoom.test.WiZoomTestClient" ] }, "mixins": [ "wi-zoom.mixins.json" ], + "accessWidener": "wi-zoom.accesswidener", "depends": { "fabricloader": ">=0.16.2", "fabric-api": ">=0.102.2", diff --git a/src/main/resources/wi-zoom.accesswidener b/src/main/resources/wi-zoom.accesswidener new file mode 100644 index 0000000..a9e23b8 --- /dev/null +++ b/src/main/resources/wi-zoom.accesswidener @@ -0,0 +1,4 @@ +accessWidener v1 named +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/widget/CyclingButtonWidget optionText Lnet/minecraft/text/Text;