Skip to content

Commit

Permalink
Implement changes to client auto-test threading (#4256)
Browse files Browse the repository at this point in the history
* Implement changes to client auto-test threading

* Replace ThrowableRunnable and ThrowableSupplier with Apache equivalents, reintroduce MinecraftClient parameter

* Avoid undefined behavior in storing Operations

* Add check for calling MinecraftClient.getInstance() on the test thread

* Add todo for suggestion of runOnClient when API methods are added

* Rename lambda parameter from minecraft -> client
  • Loading branch information
Earthcomputer authored Dec 5, 2024
1 parent a22746d commit 453d4f9
Show file tree
Hide file tree
Showing 7 changed files with 522 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@

import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.clickScreenButton;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.closeScreen;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.computeOnClient;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.connectToServer;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.enableDebugHud;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.openGameMenu;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.openInventory;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.setPerspective;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.submitAndWait;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.takeScreenshot;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForLoadingComplete;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForScreen;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForServerStop;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForTitleScreenFade;
import static net.fabricmc.fabric.test.base.client.FabricClientTestHelper.waitForWorldTicks;

Expand All @@ -35,7 +36,6 @@
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;

import com.mojang.authlib.GameProfile;
import org.spongepowered.asm.mixin.MixinEnvironment;
Expand All @@ -54,28 +54,21 @@
import net.fabricmc.loader.api.FabricLoader;

public class FabricApiAutoTestClient implements ClientModInitializer {
public static final boolean IS_AUTO_TEST = System.getProperty("fabric.autoTest") != null;

@Override
public void onInitializeClient() {
if (System.getProperty("fabric.autoTest") == null) {
if (!IS_AUTO_TEST) {
return;
}

var thread = new Thread(() -> {
try {
runTest();
} catch (Throwable t) {
t.printStackTrace();
System.exit(1);
}
});
thread.setName("Fabric Auto Test");
thread.start();
ThreadingImpl.runTestThread(this::runTest);
}

private void runTest() {
waitForLoadingComplete();

final boolean onboardAccessibility = submitAndWait(client -> client.options.onboardAccessibility);
final boolean onboardAccessibility = computeOnClient(client -> client.options.onboardAccessibility);

if (onboardAccessibility) {
waitForScreen(AccessibilityOnboardingScreen.class);
Expand All @@ -86,7 +79,7 @@ private void runTest() {
{
waitForScreen(TitleScreen.class);
waitForTitleScreenFade();
takeScreenshot("title_screen", Duration.ZERO);
takeScreenshot("title_screen", 0);
clickScreenButton("menu.singleplayer");
}

Expand All @@ -113,7 +106,7 @@ private void runTest() {
{
enableDebugHud();
waitForWorldTicks(200);
takeScreenshot("in_game_overworld", Duration.ZERO);
takeScreenshot("in_game_overworld", 0);
}

MixinEnvironment.getCurrentEnvironment().audit();
Expand All @@ -136,18 +129,19 @@ private void runTest() {
takeScreenshot("game_menu");
clickScreenButton("menu.returnToMenu");
waitForScreen(TitleScreen.class);
waitForServerStop();
}

try (var server = new TestDedicatedServer()) {
connectToServer(server);
waitForWorldTicks(5);

final GameProfile profile = submitAndWait(MinecraftClient::getGameProfile);
final GameProfile profile = computeOnClient(MinecraftClient::getGameProfile);
server.runCommand("op " + profile.getName());
server.runCommand("gamemode creative " + profile.getName());

waitForWorldTicks(20);
takeScreenshot("server_in_game", Duration.ZERO);
takeScreenshot("server_in_game", 0);

{ // Test that we can enter and exit configuration
server.runCommand("debugconfig config " + profile.getName());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@

package net.fabricmc.fabric.test.base.client;

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 org.apache.commons.lang3.function.FailableConsumer;
import org.apache.commons.lang3.function.FailableFunction;
import org.apache.commons.lang3.mutable.MutableObject;

import net.minecraft.SharedConstants;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.Drawable;
import net.minecraft.client.gui.screen.GameMenuScreen;
Expand All @@ -48,10 +50,21 @@
import net.fabricmc.fabric.test.base.client.mixin.TitleScreenAccessor;
import net.fabricmc.loader.api.FabricLoader;

// 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));
// client is not ticking and can't accept tasks, waitFor doesn't work so we'll do this until then
while (!ThreadingImpl.clientCanAcceptTasks) {
runTick();

try {
//noinspection BusyWait
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

waitFor("Loading to complete", client -> client.getOverlay() == null, 5 * SharedConstants.TICKS_PER_MINUTE);
}

public static void waitForScreen(Class<? extends Screen> screenClass) {
Expand All @@ -66,7 +79,7 @@ public static void openGameMenu() {
public static void openInventory() {
setScreen((client) -> new InventoryScreen(Objects.requireNonNull(client.player)));

boolean creative = submitAndWait(client -> Objects.requireNonNull(client.player).isCreative());
boolean creative = computeOnClient(client -> Objects.requireNonNull(client.player).isCreative());
waitForScreen(creative ? CreativeInventoryScreen.class : InventoryScreen.class);
}

Expand All @@ -75,24 +88,20 @@ public static void closeScreen() {
}

private static void setScreen(Function<MinecraftClient, Screen> screenSupplier) {
submit(client -> {
client.setScreen(screenSupplier.apply(client));
return null;
});
runOnClient(client -> client.setScreen(screenSupplier.apply(client)));
}

public static void takeScreenshot(String name) {
takeScreenshot(name, Duration.ofMillis(50));
takeScreenshot(name, 1);
}

public static void takeScreenshot(String name, Duration delay) {
public static void takeScreenshot(String name, int delayTicks) {
// Allow time for any screens to open
waitFor(delay);
runTicks(delayTicks);

submitAndWait(client -> {
runOnClient(client -> {
ScreenshotRecorder.saveScreenshot(FabricLoader.getInstance().getGameDir().toFile(), name + ".png", client.getFramebuffer(), (message) -> {
});
return null;
});
}

Expand Down Expand Up @@ -145,30 +154,23 @@ private static boolean pressMatchingButton(ClickableWidget widget, String text)

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));
waitFor("World load", client -> client.world != null && !(client.currentScreen instanceof LevelLoadingScreen), 30 * SharedConstants.TICKS_PER_MINUTE);
final long startTicks = computeOnClient(client -> client.world.getTime());
waitFor("World load", client -> Objects.requireNonNull(client.world).getTime() > startTicks + ticks, 10 * SharedConstants.TICKS_PER_MINUTE);
}

public static void enableDebugHud() {
submitAndWait(client -> {
client.inGameHud.getDebugHud().toggleDebugHud();
return null;
});
runOnClient(client -> client.inGameHud.getDebugHud().toggleDebugHud());
}

public static void setPerspective(Perspective perspective) {
submitAndWait(client -> {
client.options.setPerspective(perspective);
return null;
});
runOnClient(client -> client.options.setPerspective(perspective));
}

public static void connectToServer(TestDedicatedServer server) {
submitAndWait(client -> {
runOnClient(client -> {
final var serverInfo = new ServerInfo("localhost", server.getConnectionAddress(), ServerInfo.ServerType.OTHER);
ConnectScreen.connect(client.currentScreen, client, ServerAddress.parse(server.getConnectionAddress()), serverInfo, false, null);
return null;
});
}

Expand All @@ -182,41 +184,43 @@ public static void waitForTitleScreenFade() {
});
}

private static void waitFor(String what, Predicate<MinecraftClient> predicate) {
waitFor(what, predicate, Duration.ofSeconds(10));
public static void waitForServerStop() {
waitFor("Server stop", client -> !ThreadingImpl.isServerRunning, SharedConstants.TICKS_PER_MINUTE);
}

private static void waitFor(String what, Predicate<MinecraftClient> predicate, Duration timeout) {
final LocalDateTime end = LocalDateTime.now().plus(timeout);

while (true) {
boolean result = submitAndWait(predicate::test);
private static void waitFor(String what, Predicate<MinecraftClient> predicate) {
waitFor(what, predicate, 10 * SharedConstants.TICKS_PER_SECOND);
}

if (result) {
break;
}
private static void waitFor(String what, Predicate<MinecraftClient> predicate, int timeoutTicks) {
int tickCount;

if (LocalDateTime.now().isAfter(end)) {
throw new RuntimeException("Timed out waiting for " + what);
}
for (tickCount = 0; tickCount < timeoutTicks && !computeOnClient(predicate::test); tickCount++) {
runTick();
}

waitFor(Duration.ofMillis(50));
if (tickCount == timeoutTicks && !computeOnClient(predicate::test)) {
throw new RuntimeException("Timed out waiting for " + what);
}
}

private static void waitFor(Duration duration) {
try {
Thread.sleep(duration.toMillis());
} catch (InterruptedException e) {
throw new RuntimeException(e);
public static void runTicks(int ticks) {
for (int i = 0; i < ticks; i++) {
runTick();
}
}

private static <T> CompletableFuture<T> submit(Function<MinecraftClient, T> function) {
return MinecraftClient.getInstance().submit(() -> function.apply(MinecraftClient.getInstance()));
public static void runTick() {
ThreadingImpl.runTick();
}

public static <E extends Throwable> void runOnClient(FailableConsumer<MinecraftClient, E> action) throws E {
ThreadingImpl.runOnClient(() -> action.accept(MinecraftClient.getInstance()));
}

public static <T> T submitAndWait(Function<MinecraftClient, T> function) {
return submit(function).join();
public static <T, E extends Throwable> T computeOnClient(FailableFunction<MinecraftClient, T, E> action) throws E {
MutableObject<T> result = new MutableObject<>();
runOnClient(client -> result.setValue(action.apply(client)));
return result.getValue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,9 @@
import java.nio.file.Paths;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;

import net.minecraft.server.Main;
import net.minecraft.server.dedicated.MinecraftDedicatedServer;
Expand All @@ -51,25 +49,14 @@ public String getConnectionAddress() {
}

public void runCommand(String command) {
submitAndWait(server -> {
server.enqueueCommand(command, server.getCommandSource());
return null;
});
ThreadingImpl.runOnServer(() -> server.getCommandManager().executeWithPrefix(server.getCommandSource(), command));
}

private void run() {
setupServer();
Main.main(new String[]{});
}

private <T> CompletableFuture<T> submit(Function<MinecraftDedicatedServer, T> function) {
return server.submit(() -> function.apply(server));
}

private <T> T submitAndWait(Function<MinecraftDedicatedServer, T> function) {
return submit(function).join();
}

private void setupServer() {
try {
Files.writeString(Paths.get("eula.txt"), "eula=true");
Expand Down Expand Up @@ -100,7 +87,12 @@ private void waitUntilReady() {

@Override
public void close() {
server.stop(true);
server.stop(false);

while (server.getThread().isAlive()) {
ThreadingImpl.runTick();
}

executor.close();
}
}
Loading

0 comments on commit 453d4f9

Please sign in to comment.