diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 7af428c..b76e252 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -98,3 +98,55 @@ jobs: echo "- [$filename]($url)" >> $GITHUB_STEP_SUMMARY done echo "" >> $GITHUB_STEP_SUMMARY + + - name: Run the mod and take screenshots + uses: modmuss50/xvfb-action@c56c7da0c8fc9a7cb5df2e50dd2a43a80b64c5cb + with: + run: ./gradlew runEndToEndTest --stacktrace --warning-mode=fail + + # Needed because the screenshot gallery won't be created on pull requests. + # Also useful if Imgur uploads fail. + - name: Upload Test Screenshots.zip artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: Test Screenshots + path: run/screenshots + + - name: Create test screenshot gallery + if: ${{ env.IMGUR_CLIENT_ID && (success() || failure()) }} + # Imgur uploads randomly fail sometimes, probably because of the low rate limit. + # TODO: Find a better place to upload the screenshots. + continue-on-error: true + run: | + echo "
" >> $GITHUB_STEP_SUMMARY + echo "📸 Test Screenshots" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + for img in run/screenshots/*.png; do + if [ -f "$img" ]; then + filename=$(basename "$img") + name_without_ext="${filename%.*}" + + # Upload to Imgur + response=$(curl -s -X POST \ + -H "Authorization: Client-ID $IMGUR_CLIENT_ID" \ + -F "image=@$img" \ + https://api.imgur.com/3/image) + + # Extract the URL from the response + url=$(echo $response | grep -o '"link":"[^"]*"' | cut -d'"' -f4) + + if [ ! -z "$url" ]; then + # Convert underscores to spaces and capitalize first letter of each word + title=$(echo "$name_without_ext" | tr '_' ' ' | awk '{for(i=1;i<=NF;i++)sub(/./,toupper(substr($i,1,1)),$i)}1') + echo "### $title" >> $GITHUB_STEP_SUMMARY + echo "![${name_without_ext}]($url)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + else + echo "Failed to upload $filename" >> $GITHUB_STEP_SUMMARY + echo "Imgur upload response for $filename: $response" + fi + fi + done + echo "
" >> $GITHUB_STEP_SUMMARY diff --git a/build.gradle b/build.gradle index 037c3d1..15477b4 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,55 @@ dependencies { modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" } +loom { + accessWidenerPath = file("src/main/resources/wi-zoom.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}", + "-Dwi_zoom.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/zoom/test/WiModsTestHelper.java b/src/main/java/net/wurstclient/zoom/test/WiModsTestHelper.java new file mode 100644 index 0000000..0e1fb9a --- /dev/null +++ b/src/main/java/net/wurstclient/zoom/test/WiModsTestHelper.java @@ -0,0 +1,399 @@ +/* + * 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 java.io.File; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import com.mojang.brigadier.ParseResults; +import com.mojang.brigadier.exceptions.CommandSyntaxException; + +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.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.TextFieldWidget; +import net.minecraft.client.network.ClientPlayNetworkHandler; +import net.minecraft.client.option.Perspective; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.client.tutorial.TutorialStep; +import net.minecraft.client.util.ScreenshotRecorder; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; + +public enum WiModsTestHelper +{ + ; + + private static final AtomicInteger screenshotCounter = new AtomicInteger(0); + + /** + * Runs the given consumer on Minecraft's main thread and waits for it to + * complete. + */ + public static void submitAndWait(Consumer consumer) + { + MinecraftClient mc = MinecraftClient.getInstance(); + mc.submit(() -> consumer.accept(mc)).join(); + } + + /** + * Runs the given function on Minecraft's main thread, waits for it to + * complete, and returns the result. + */ + public static T submitAndGet(Function function) + { + MinecraftClient mc = MinecraftClient.getInstance(); + return mc.submit(() -> function.apply(mc)).join(); + } + + /** + * Waits for the given duration. + */ + public static void wait(Duration duration) + { + try + { + Thread.sleep(duration.toMillis()); + + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + } + + /** + * Waits until the given condition is true, or fails if the timeout is + * reached. + */ + public static void waitUntil(String event, + Predicate condition, Duration maxDuration) + { + LocalDateTime startTime = LocalDateTime.now(); + LocalDateTime timeout = startTime.plus(maxDuration); + System.out.println("Waiting until " + event); + + while(true) + { + if(submitAndGet(condition::test)) + { + double seconds = + Duration.between(startTime, LocalDateTime.now()).toMillis() + / 1000.0; + System.out.println( + "Waiting until " + event + " took " + seconds + "s"); + break; + } + + if(startTime.isAfter(timeout)) + throw new RuntimeException( + "Waiting until " + event + " took too long"); + + wait(Duration.ofMillis(50)); + } + } + + /** + * Waits until the given condition is true, or fails after 10 seconds. + */ + public static void waitUntil(String event, + Predicate condition) + { + waitUntil(event, condition, Duration.ofSeconds(10)); + } + + /** + * Waits until the given screen is open, or fails after 10 seconds. + */ + public static void waitForScreen(Class screenClass) + { + waitUntil("screen " + screenClass.getName() + " is open", + mc -> screenClass.isInstance(mc.currentScreen)); + } + + /** + * Waits for the fading animation of the title screen to finish, or fails + * after 10 seconds. + */ + public static void waitForTitleScreenFade() + { + waitUntil("title screen fade is complete", mc -> { + if(!(mc.currentScreen instanceof TitleScreen titleScreen)) + return false; + + return !titleScreen.doBackgroundFade; + }); + } + + /** + * Waits until the red overlay with the Mojang logo and progress bar goes + * away, or fails after 5 minutes. + */ + public static void waitForResourceLoading() + { + waitUntil("loading is complete", mc -> mc.getOverlay() == null, + Duration.ofMinutes(5)); + } + + public static void waitForWorldLoad() + { + waitUntil("world is loaded", + mc -> mc.world != null + && !(mc.currentScreen instanceof LevelLoadingScreen), + Duration.ofMinutes(30)); + } + + public static void waitForWorldTicks(int ticks) + { + long startTicks = submitAndGet(mc -> mc.world.getTime()); + waitUntil(ticks + " world ticks have passed", + mc -> mc.world.getTime() >= startTicks + ticks, + Duration.ofMillis(ticks * 100).plusMinutes(5)); + } + + /** + * Waits for 50ms and then takes a screenshot with the given name. + */ + public static void takeScreenshot(String name) + { + takeScreenshot(name, Duration.ofMillis(50)); + } + + /** + * Waits for the given delay and then takes a screenshot with the given + * name. + */ + public static void takeScreenshot(String name, Duration delay) + { + wait(delay); + + String count = + String.format("%02d", screenshotCounter.incrementAndGet()); + String filename = count + "_" + name + ".png"; + File gameDir = FabricLoader.getInstance().getGameDir().toFile(); + + submitAndWait(mc -> ScreenshotRecorder.saveScreenshot(gameDir, filename, + mc.getFramebuffer(), message -> {})); + } + + /** + * Returns the first button on the current screen that has the given + * translation key, or fails if not found. + * + *

+ * 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;