Add experimental Client Game Test API ()

* Move client auto tests to new module fabric-client-gametest-api-v1

* Fix run prod Gradle tasks

* Rename remaining references to client auto-tests

* Switch client gametests to using entrypoints

* Disable input and cursor grabbing

* Remove FabricClientTestHelper moving most of it into the context. Add the ability to simulate key and mouse inputs

* Rename and document input methods

* Rename client gametest github action

* Fix tryClickScreenButtonImpl for buttons inside layout widgets

* Address review comments and repackage client gametest test

* Delete wrong reference to TitleScreenAccessor. Thanks mcdev

* Address review comments

* Clean up default game options

* Improve documentation

* Remove module dependencies

---------

Co-authored-by: modmuss50 <modmuss50@gmail.com>
This commit is contained in:
Joseph Burton 2024-12-16 11:32:46 +00:00 committed by GitHub
parent 08f5ef8bb2
commit b47eab6b15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1983 additions and 550 deletions

View file

@ -43,10 +43,10 @@ jobs:
with:
distribution: 'microsoft'
java-version: '21'
- name: Run Auto test Client
- name: Run Client Gametests
uses: modmuss50/xvfb-action@v1
with:
run: ./gradlew runProductionAutoTestClient --stacktrace --warning-mode=fail
run: ./gradlew runProductionClientGametest --stacktrace --warning-mode=fail
- uses: actions/upload-artifact@v4
if: always()
with:

View file

@ -417,10 +417,10 @@ loom {
name "Auto Test Server"
vmArg "-Dfabric.autoTest"
}
autoTestClient {
clientGametest {
inherit testmodClient
name "Auto Test Client"
vmArg "-Dfabric.autoTest"
name "Client Game Test"
vmArg "-Dfabric.client.gametest"
vmArg "-Dfabric-tag-conventions-v2.missingTagTranslationWarning=fail"
vmArg "-Dfabric-tag-conventions-v1.legacyTagWarning=fail"
}
@ -432,9 +432,9 @@ loom {
ideConfigGenerated = false
}
autoTestClientCoverage {
inherit autoTestClient
name "Auto Test Client Coverage"
clientGametestCoverage {
inherit clientGametest
name "Client Game Test Coverage"
ideConfigGenerated = false
}
}
@ -448,7 +448,7 @@ test.dependsOn runGametest
def coverageTasks = [
runGametestCoverage,
runAutoTestClientCoverage
runClientGametestCoverage
]
jacoco {
@ -484,6 +484,9 @@ configurations {
extendsFrom configurations.minecraftRuntimeLibraries
}
productionRuntimeServer
productionMods {
transitive = false
}
}
dependencies {
@ -491,13 +494,16 @@ dependencies {
productionRuntime "net.fabricmc:intermediary:${project.minecraft_version}"
productionRuntimeServer "net.fabricmc:fabric-installer:${project.installer_version}:server"
productionMods project(':fabric-client-gametest-api-v1')
}
import net.fabricmc.loom.util.Platform
def productionMods = project.files(configurations.productionMods, remapJar.archiveFile, remapTestmodJar.archiveFile)
// This is very far beyond loom's API if you copy this, you're on your own.
tasks.register('runProductionAutoTestClient', JavaExec) {
dependsOn remapJar, remapTestmodJar, downloadAssets
tasks.register('runProductionClientGametest', JavaExec) {
dependsOn productionMods, downloadAssets
classpath.from configurations.productionRuntime
mainClass = "net.fabricmc.loader.impl.launch.knot.KnotClient"
workingDir = file("run")
@ -519,8 +525,8 @@ tasks.register('runProductionAutoTestClient', JavaExec) {
}
jvmArgs(
"-Dfabric.addMods=${remapJar.archiveFile.get().asFile.absolutePath}${File.pathSeparator}${remapTestmodJar.archiveFile.get().asFile.absolutePath}",
"-Dfabric.autoTest",
"-Dfabric.addMods=${productionMods.collect { it.absolutePath }.join(File.pathSeparator)}",
"-Dfabric.client.gametest",
"-Dfabric-tag-conventions-v2.missingTagTranslationWarning=fail",
"-Dfabric-tag-conventions-v1.legacyTagWarning=fail"
)
@ -544,7 +550,7 @@ tasks.register('serverPropertiesJar', Jar) {
}
tasks.register('runProductionAutoTestServer', JavaExec) {
dependsOn remapJar, remapTestmodJar, serverPropertiesJar
dependsOn productionMods, serverPropertiesJar
classpath.from configurations.productionRuntimeServer, serverPropertiesJar
mainClass = "net.fabricmc.installer.ServerLauncher"
workingDir = file("run")
@ -553,7 +559,7 @@ tasks.register('runProductionAutoTestServer', JavaExec) {
workingDir.mkdirs()
jvmArgs(
"-Dfabric.addMods=${remapJar.archiveFile.get().asFile.absolutePath}${File.pathSeparator}${remapTestmodJar.archiveFile.get().asFile.absolutePath}",
"-Dfabric.addMods=${productionMods.collect { it.absolutePath }.join(File.pathSeparator)}",
"-Dfabric.autoTest",
)
jvmArgs(debugArgs)
@ -726,7 +732,10 @@ subprojects.each {
}
// These modules are not included in the fat jar, maven will resolve them via the pom.
def devOnlyModules = ["fabric-gametest-api-v1",]
def devOnlyModules = [
"fabric-client-gametest-api-v1",
"fabric-gametest-api-v1",
]
dependencies {
afterEvaluate {

View file

@ -9,20 +9,11 @@
"main": [
"net.fabricmc.fabric.test.base.FabricApiBaseTestInit"
],
"client": [
"net.fabricmc.fabric.test.base.client.FabricApiAutoTestClient"
],
"server": [
"net.fabricmc.fabric.test.base.FabricApiAutoTestServer"
],
"fabric-gametest" : [
"net.fabricmc.fabric.test.base.FabricApiBaseGameTest"
]
},
"mixins": [
{
"config": "fabric-api-base-testmod.client.mixins.json",
"environment": "client"
}
]
}
}

View file

@ -1,175 +0,0 @@
/*
* 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.fabricmc.fabric.test.base.client;
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.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;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import com.mojang.authlib.GameProfile;
import org.spongepowered.asm.mixin.MixinEnvironment;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.screen.AccessibilityOnboardingScreen;
import net.minecraft.client.gui.screen.ConfirmScreen;
import net.minecraft.client.gui.screen.ReconfiguringScreen;
import net.minecraft.client.gui.screen.TitleScreen;
import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen;
import net.minecraft.client.gui.screen.world.CreateWorldScreen;
import net.minecraft.client.gui.screen.world.SelectWorldScreen;
import net.minecraft.client.option.Perspective;
import net.fabricmc.api.ClientModInitializer;
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 (!IS_AUTO_TEST) {
return;
}
ThreadingImpl.runTestThread(this::runTest);
}
private void runTest() {
waitForLoadingComplete();
final boolean onboardAccessibility = computeOnClient(client -> client.options.onboardAccessibility);
if (onboardAccessibility) {
waitForScreen(AccessibilityOnboardingScreen.class);
takeScreenshot("onboarding_screen");
clickScreenButton("gui.continue");
}
{
waitForScreen(TitleScreen.class);
waitForTitleScreenFade();
takeScreenshot("title_screen", 0);
clickScreenButton("menu.singleplayer");
}
if (!isDirEmpty(FabricLoader.getInstance().getGameDir().resolve("saves"))) {
waitForScreen(SelectWorldScreen.class);
takeScreenshot("select_world_screen");
clickScreenButton("selectWorld.create");
}
{
waitForScreen(CreateWorldScreen.class);
clickScreenButton("selectWorld.gameMode");
clickScreenButton("selectWorld.gameMode");
takeScreenshot("create_world_screen");
clickScreenButton("selectWorld.create");
}
{
// API test mods use experimental features
waitForScreen(ConfirmScreen.class);
clickScreenButton("gui.yes");
}
{
enableDebugHud();
waitForWorldTicks(200);
takeScreenshot("in_game_overworld", 0);
}
MixinEnvironment.getCurrentEnvironment().audit();
{
// See if the player render events are working.
setPerspective(Perspective.THIRD_PERSON_BACK);
takeScreenshot("in_game_overworld_third_person");
setPerspective(Perspective.FIRST_PERSON);
}
{
openInventory();
takeScreenshot("in_game_inventory");
closeScreen();
}
{
openGameMenu();
takeScreenshot("game_menu");
clickScreenButton("menu.returnToMenu");
waitForScreen(TitleScreen.class);
waitForServerStop();
}
try (var server = new TestDedicatedServer()) {
connectToServer(server);
waitForWorldTicks(5);
final GameProfile profile = computeOnClient(MinecraftClient::getGameProfile);
server.runCommand("op " + profile.getName());
server.runCommand("gamemode creative " + profile.getName());
waitForWorldTicks(20);
takeScreenshot("server_in_game", 0);
{ // Test that we can enter and exit configuration
server.runCommand("debugconfig config " + profile.getName());
waitForScreen(ReconfiguringScreen.class);
takeScreenshot("server_config");
server.runCommand("debugconfig unconfig " + profile.getId());
waitForWorldTicks(1);
}
openGameMenu();
takeScreenshot("server_game_menu");
clickScreenButton("menu.disconnect");
waitForScreen(MultiplayerScreen.class);
clickScreenButton("gui.back");
}
{
waitForScreen(TitleScreen.class);
clickScreenButton("menu.quit");
}
}
private boolean isDirEmpty(Path path) {
try (DirectoryStream<Path> directory = Files.newDirectoryStream(path)) {
return !directory.iterator().hasNext();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}

View file

@ -1,226 +0,0 @@
/*
* 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.fabricmc.fabric.test.base.client;
import java.util.Objects;
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;
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.multiplayer.ConnectScreen;
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.network.ServerAddress;
import net.minecraft.client.network.ServerInfo;
import net.minecraft.client.option.Perspective;
import net.minecraft.client.util.ScreenshotRecorder;
import net.minecraft.text.Text;
import net.fabricmc.fabric.test.base.client.mixin.CyclingButtonWidgetAccessor;
import net.fabricmc.fabric.test.base.client.mixin.ScreenAccessor;
import net.fabricmc.fabric.test.base.client.mixin.TitleScreenAccessor;
import net.fabricmc.loader.api.FabricLoader;
public final class FabricClientTestHelper {
public static void waitForLoadingComplete() {
// 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) {
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 = computeOnClient(client -> Objects.requireNonNull(client.player).isCreative());
waitForScreen(creative ? CreativeInventoryScreen.class : InventoryScreen.class);
}
public static void closeScreen() {
setScreen((client) -> null);
}
private static void setScreen(Function<MinecraftClient, Screen> screenSupplier) {
runOnClient(client -> client.setScreen(screenSupplier.apply(client)));
}
public static void takeScreenshot(String name) {
takeScreenshot(name, 1);
}
public static void takeScreenshot(String name, int delayTicks) {
// Allow time for any screens to open
runTicks(delayTicks);
runOnClient(client -> {
ScreenshotRecorder.saveScreenshot(FabricLoader.getInstance().getGameDir().toFile(), name + ".png", client.getFramebuffer(), (message) -> {
});
});
}
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;
}
final ScreenAccessor screenAccessor = (ScreenAccessor) screen;
for (Drawable drawable : screenAccessor.getDrawables()) {
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) {
if (text.equals(buttonWidget.getMessage().getString())) {
buttonWidget.onPress();
return true;
}
}
if (widget instanceof CyclingButtonWidget<?> buttonWidget) {
CyclingButtonWidgetAccessor accessor = (CyclingButtonWidgetAccessor) buttonWidget;
if (text.equals(accessor.getOptionText().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), 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() {
runOnClient(client -> client.inGameHud.getDebugHud().toggleDebugHud());
}
public static void setPerspective(Perspective perspective) {
runOnClient(client -> client.options.setPerspective(perspective));
}
public static void connectToServer(TestDedicatedServer server) {
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);
});
}
public static void waitForTitleScreenFade() {
waitFor("Title screen fade", client -> {
if (!(client.currentScreen instanceof TitleScreen titleScreen)) {
return false;
}
return !((TitleScreenAccessor) titleScreen).getDoBackgroundFade();
});
}
public static void waitForServerStop() {
waitFor("Server stop", client -> !ThreadingImpl.isServerRunning, SharedConstants.TICKS_PER_MINUTE);
}
private static void waitFor(String what, Predicate<MinecraftClient> predicate) {
waitFor(what, predicate, 10 * SharedConstants.TICKS_PER_SECOND);
}
private static void waitFor(String what, Predicate<MinecraftClient> predicate, int timeoutTicks) {
int tickCount;
for (tickCount = 0; tickCount < timeoutTicks && !computeOnClient(predicate::test); tickCount++) {
runTick();
}
if (tickCount == timeoutTicks && !computeOnClient(predicate::test)) {
throw new RuntimeException("Timed out waiting for " + what);
}
}
public static void runTicks(int ticks) {
for (int i = 0; i < ticks; i++) {
runTick();
}
}
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, 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();
}
}

View file

@ -1,18 +0,0 @@
{
"required": true,
"package": "net.fabricmc.fabric.test.base.client.mixin",
"compatibilityLevel": "JAVA_21",
"client": [
"CyclingButtonWidgetAccessor",
"MinecraftClientMixin",
"MinecraftDedicatedServerMixin",
"ScreenAccessor",
"TitleScreenAccessor"
],
"injectors": {
"defaultRequire": 1
},
"mixins": [
"MinecraftServerMixin"
]
}

View file

@ -0,0 +1,5 @@
version = getSubprojectVersion(project)
loom {
accessWidenerPath = file('src/client/resources/fabric-client-gametest-api-v1.accesswidener')
}

View file

@ -0,0 +1,157 @@
/*
* 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.fabricmc.fabric.api.client.gametest.v1;
import java.nio.file.Path;
import java.util.function.Predicate;
import java.util.function.Supplier;
import org.apache.commons.lang3.function.FailableConsumer;
import org.apache.commons.lang3.function.FailableFunction;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Nullable;
import net.minecraft.SharedConstants;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.screen.Screen;
/**
* Context for a client gametest containing various helpful functions and functions to access the game. Functions in
* this class can only be called on the client gametest thread.
*/
@ApiStatus.NonExtendable
public interface ClientGameTestContext {
/**
* Used to specify that a wait task should have no timeout.
*/
int NO_TIMEOUT = -1;
/**
* The default timeout in ticks for wait tasks (10 seconds).
*/
int DEFAULT_TIMEOUT = 10 * SharedConstants.TICKS_PER_SECOND;
/**
* Runs a single tick and waits for it to complete.
*/
void waitTick();
/**
* Runs {@code ticks} ticks and waits for them to complete.
*
* @param ticks The amount of ticks to run
*/
void waitTicks(int ticks);
/**
* Waits for a predicate to be true. Fails if the predicate is not satisfied after {@link #DEFAULT_TIMEOUT} ticks.
*
* @param predicate The predicate to check
*/
void waitFor(Predicate<MinecraftClient> predicate);
/**
* Waits for a predicate to be true. Fails if the predicate is not satisfied after {@code timeout} ticks. If
* {@code timeout} is {@link #NO_TIMEOUT}, there is no timeout.
*
* @param predicate The predicate to check
* @param timeout The number of ticks before timing out
*/
void waitFor(Predicate<MinecraftClient> predicate, int timeout);
/**
* Waits for the given screen class to be shown. If {@code screenClass} is {@code null}, waits for the current
* screen to be {@code null}. Fails if the screen does not open after {@link #DEFAULT_TIMEOUT} ticks.
*
* @param screenClass The screen class to wait to open
*/
void waitForScreen(@Nullable Class<? extends Screen> screenClass);
/**
* Opens a {@link Screen} on the client.
*
* @param screen The screen to open
* @see MinecraftClient#setScreen(Screen)
*/
void setScreen(Supplier<@Nullable Screen> screen);
/**
* Presses the button in the current screen whose label is the given translation key. Fails if the button couldn't
* be found.
*
* @param translationKey The translation key of the label of the button to press
*/
void clickScreenButton(String translationKey);
/**
* Presses the button in the current screen whose label is the given translation key, if the button exists. Returns
* whether the button was found.
*
* @param translationKey The translation key of the label of the button to press
* @return Whether the button was found
*/
boolean tryClickScreenButton(String translationKey);
/**
* Takes a screenshot after waiting 1 tick (for a frame to render) and saves it in the screenshots directory.
*
* @param name The name of the screenshot
*/
Path takeScreenshot(String name);
/**
* Takes a screnshot after waiting {@code delay} ticks and saves it in the screenshots directory.
*
* @param name The name of the screenshot
* @param delay The delay in ticks before taking the screenshot
*/
Path takeScreenshot(String name, int delay);
/**
* Gets the input handler used to simulate inputs to the client.
*
* @return The client gametest input handler
*/
ClientGameTestInput getInput();
/**
* Restores all game options in {@link MinecraftClient#options} to their default values for client gametests. This
* is called automatically before each gametest is run, so you only need to call this explicitly if you want to do
* it in the middle of the test.
*/
void restoreDefaultGameOptions();
/**
* Runs the given action on the render thread (client thread), and waits for it to complete.
*
* @param action The action to run on the render thread
* @param <E> The type of checked exception that the action throws
* @throws E When the action throws an exception
*/
<E extends Throwable> void runOnClient(FailableConsumer<MinecraftClient, E> action) throws E;
/**
* Runs the given function on the render thread (client thread), and returns the result.
*
* @param function The function to run on the render thread
* @return The result of the function
* @param <T> The type of the value to return
* @param <E> The type of the checked exception that the function throws
* @throws E When the function throws an exception
*/
<T, E extends Throwable> T computeOnClient(FailableFunction<MinecraftClient, T, E> function) throws E;
}

View file

@ -0,0 +1,328 @@
/*
* 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.fabricmc.fabric.api.client.gametest.v1;
import java.util.function.Function;
import org.jetbrains.annotations.ApiStatus;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.option.GameOptions;
import net.minecraft.client.option.KeyBinding;
import net.minecraft.client.util.InputUtil;
/**
* The client gametest input handler used to simulate inputs to the client.
*/
@ApiStatus.NonExtendable
public interface ClientGameTestInput {
/**
* Starts holding down a key binding. The key binding will be held until it is released. The key binding must be
* bound. Does nothing if the key binding is already being held.
*
* @param keyBinding The key binding to hold
* @see #releaseKey(KeyBinding)
* @see #pressKey(KeyBinding)
* @see #holdKey(Function)
*/
void holdKey(KeyBinding keyBinding);
/**
* Starts holding down a key binding. The key binding will be held until it is released. The key binding must be
* bound. Does nothing if the key binding is already being held.
*
* @param keyBindingGetter The function to get the key binding from the game options
* @see #releaseKey(Function)
* @see #pressKey(Function)
* @see #holdKey(KeyBinding)
*/
void holdKey(Function<GameOptions, KeyBinding> keyBindingGetter);
/**
* Starts holding down a key or mouse button. The key will be held until it is released. Does nothing if the key or
* mouse button is already being held.
*
* @param key The key or mouse button to hold
* @see #releaseKey(InputUtil.Key)
* @see #pressKey(InputUtil.Key)
*/
void holdKey(InputUtil.Key key);
/**
* Starts holding down a key. The key will be held until it is released. Does nothing if the key is already being
* held.
*
* @param keyCode The key code of the key to hold
* @see #releaseKey(int)
* @see #pressKey(int)
*/
void holdKey(int keyCode);
/**
* Starts holding down a mouse button. The mouse button will be held until it is released. Does nothing if the mouse
* button is already being held.
*
* @param button The mouse button to hold
* @see #releaseMouse(int)
* @see #pressMouse(int)
*/
void holdMouse(int button);
/**
* Starts holding down left control, or left super on macOS. Suitable for triggering
* {@link Screen#hasControlDown()}. The key will be held until it is released. Does nothing if the key is already
* being held.
*
* @see #releaseControl()
*/
void holdControl();
/**
* Starts holding down left shift. Suitable for triggering {@link Screen#hasShiftDown()}. The key will be held until
* it is released. Does nothing if the key is already being held.
*
* @see #releaseShift()
*/
void holdShift();
/**
* Starts holding down left alt. Suitable for triggering {@link Screen#hasAltDown()}. The key will be held until it
* is released. Does nothing if the key is already being held.
*
* @see #releaseAlt()
*/
void holdAlt();
/**
* Releases a key binding. The key binding must be bound. Does nothing if the key binding is not being held.
*
* @param keyBinding The key binding to release
* @see #holdKey(KeyBinding)
* @see #releaseKey(Function)
*/
void releaseKey(KeyBinding keyBinding);
/**
* Releases a key binding. The key binding must be bound. Does nothing if the key binding is not being held.
*
* @param keyBindingGetter The function to get the key binding from the game options
* @see #holdKey(Function)
* @see #releaseKey(KeyBinding)
*/
void releaseKey(Function<GameOptions, KeyBinding> keyBindingGetter);
/**
* Releases a key or mouse button. Does nothing if the key or mouse button is not being held.
*
* @param key The key or mouse button to release
* @see #holdKey(InputUtil.Key)
*/
void releaseKey(InputUtil.Key key);
/**
* Releases a key. Does nothing if the key is not being held.
*
* @param keyCode The GLFW key code of the key to release
* @see #holdKey(int)
*/
void releaseKey(int keyCode);
/**
* Releases a mouse button. Does nothing if the mouse button is not being held.
*
* @param button The GLFW mouse button to release
* @see #holdMouse(int)
*/
void releaseMouse(int button);
/**
* Releases left control, or left super on macOS. Suitable for un-triggering {@link Screen#hasControlDown()}. Does
* nothing if the key is not being held.
*
* @see #holdControl()
*/
void releaseControl();
/**
* Releases left shift. Suitable for un-triggering {@link Screen#hasShiftDown()}. Does nothing if the key is not
* being held.
*
* @see #holdShift()
*/
void releaseShift();
/**
* Releases left alt. Suitable for un-triggering {@link Screen#hasAltDown()}. Does nothing if the key is not being
* held.
*
* @see #holdAlt()
*/
void releaseAlt();
/**
* Presses and releases a key binding. The key binding must be bound.
*
* @param keyBinding The key binding to press
* @see #holdKey(KeyBinding)
* @see #pressKey(Function)
*/
void pressKey(KeyBinding keyBinding);
/**
* Presses and releases a key binding. The key binding must be bound.
*
* @param keyBindingGetter The function to get the key binding from the game options
* @see #holdKey(Function)
* @see #pressKey(KeyBinding)
*/
void pressKey(Function<GameOptions, KeyBinding> keyBindingGetter);
/**
* Presses and releases a key or mouse button.
*
* @param key The key or mouse button to press.
* @see #holdKey(InputUtil.Key)
*/
void pressKey(InputUtil.Key key);
/**
* Presses and releases a key.
*
* <p>For sending Unicode text input (e.g. into text boxes), use {@link #typeChar(int)} or
* {@link #typeChars(String)} instead.
*
* @param keyCode The GLFW key code of the key to press
* @see #holdKey(int)
*/
void pressKey(int keyCode);
/**
* Presses and releases a mouse button.
*
* @param button The GLFW mouse button to press
* @see #holdMouse(int)
*/
void pressMouse(int button);
/**
* Holds a key binding for the specified number of ticks and then releases it. Waits until this process is finished.
* The key binding must be bound.
*
* @param keyBinding The key binding to hold
* @param ticks The number of ticks to hold the key binding for
* @see #holdKey(KeyBinding)
* @see #holdKeyFor(Function, int)
*/
void holdKeyFor(KeyBinding keyBinding, int ticks);
/**
* Holds a key binding for the specified number of ticks and then releases it. Waits until this process is finished.
* The key binding must be bound.
*
* @param keyBindingGetter The key binding to hold
* @param ticks The number of ticks to hold the key binding for
* @see #holdKey(Function)
* @see #holdKeyFor(Function, int)
*/
void holdKeyFor(Function<GameOptions, KeyBinding> keyBindingGetter, int ticks);
/**
* Holds a key or mouse button for the specified number of ticks and then releases it. Waits until this process is
* finished.
*
* @param key The key or mouse button to hold
* @param ticks The number of ticks to hold the key or mouse button for
* @see #holdKey(InputUtil.Key)
*/
void holdKeyFor(InputUtil.Key key, int ticks);
/**
* Holds a key for the specified number of ticks and then releases it. Waits until this process is finished.
*
* @param keyCode The GLFW key code of the key to hold
* @param ticks The number of ticks to hold the key for
* @see #holdKey(int)
*/
void holdKeyFor(int keyCode, int ticks);
/**
* Holds a mouse button for the specified number of ticks and then releases it. Waits until this process is
* finished.
*
* @param button The GLFW mouse button to hold
* @param ticks The number of ticks to hold the mouse button for
* @see #holdMouse(int)
*/
void holdMouseFor(int button, int ticks);
/**
* Types a code point (character). Useful for typing in text boxes.
*
* <p>This method is for sending Unicode text input, <em>not</em> for pressing keys on the keyboard for other
* purposes, such as pressing {@code W} for moving the player. For those use cases, use one of the {@code pressKey}
* overloads instead.
*
* @param codePoint The code point to type
* @see #typeChars(String)
* @see #pressKey(int)
* @see #pressKey(KeyBinding)
* @see #pressKey(Function)
*/
void typeChar(int codePoint);
/**
* Types a sequence of code points (characters) one after the other. Useful for typing in text boxes.
*
* @param chars The code points to type
*/
void typeChars(String chars);
/**
* Scrolls the mouse vertically.
*
* @param amount The amount to scroll by
* @see #scroll(double, double)
*/
void scroll(double amount);
/**
* Scrolls the mouse horizontally and vertically.
*
* @param xAmount The horizontal amount to scroll by
* @param yAmount The vertical amount to scroll by
* @see #scroll(double)
*/
void scroll(double xAmount, double yAmount);
/**
* Sets the cursor position.
*
* @param x The x position of the new cursor position
* @param y The y position of the new cursor position
* @see #moveCursor(double, double)
*/
void setCursorPos(double x, double y);
/**
* Moves the cursor position.
*
* @param deltaX The amount to add to the x position of the cursor
* @param deltaY The amount to add to the y position of the cursor
* @see #setCursorPos(double, double)
*/
void moveCursor(double deltaX, double deltaY);
}

View file

@ -0,0 +1,27 @@
/*
* 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.fabricmc.fabric.api.client.gametest.v1;
/**
* The {@code fabric-client-gametest} entrypoint interface. See the package documentation.
*/
public interface FabricClientGameTest {
/**
* Runs the gametest.
*/
void runTest(ClientGameTestContext context);
}

View file

@ -0,0 +1,25 @@
/**
* Provides support for client gametests. To register a client gametest, add an entry to the
* {@code fabric-client-gametest} entrypoint in your {@code fabric.mod.json}. Your gametest class should implement
* {@link net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest FabricClientGameTest}.
*
* <h1>Lifecycle</h1>
* Client gametests are run sequentially. When a gametest ends, the game will be
* returned to the title screen. When all gametests have been run, the game will be closed.
*
* <h1>Threading</h1>
*
* <p>Client gametests run on the client gametest thread. Use the functions inside
* {@link net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestContext ClientGameTestContext} and other test helper
* classes to run code on the correct thread. The game remains paused unless you explicitly unpause it using various
* waiting functions such as
* {@link net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestContext#waitTick() ClientGameTestContext.waitTick()}.
*
* <p>A few changes have been made to how the vanilla game threads run, to make tests more reproducible. Notably, there
* is exactly one server tick per client tick while a server is running (singleplayer or multiplayer). On singleplayer,
* packets will always arrive on a consistent tick.
*/
@ApiStatus.Experimental
package net.fabricmc.fabric.api.client.gametest.v1;
import org.jetbrains.annotations.ApiStatus;

View file

@ -0,0 +1,335 @@
/*
* 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.fabricmc.fabric.impl.client.gametest;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import com.google.common.base.Preconditions;
import org.apache.commons.lang3.function.FailableConsumer;
import org.apache.commons.lang3.function.FailableFunction;
import org.apache.commons.lang3.mutable.MutableBoolean;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.Drawable;
import net.minecraft.client.gui.screen.Screen;
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.CloudRenderMode;
import net.minecraft.client.option.GameOptions;
import net.minecraft.client.option.SimpleOption;
import net.minecraft.client.tutorial.TutorialStep;
import net.minecraft.client.util.ScreenshotRecorder;
import net.minecraft.sound.SoundCategory;
import net.minecraft.text.Text;
import net.minecraft.util.Nullables;
import net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestContext;
import net.fabricmc.fabric.mixin.client.gametest.CyclingButtonWidgetAccessor;
import net.fabricmc.fabric.mixin.client.gametest.GameOptionsAccessor;
import net.fabricmc.fabric.mixin.client.gametest.ScreenAccessor;
import net.fabricmc.loader.api.FabricLoader;
public final class ClientGameTestContextImpl implements ClientGameTestContext {
private final ClientGameTestInputImpl input = new ClientGameTestInputImpl(this);
private static final Map<String, Object> DEFAULT_GAME_OPTIONS = new HashMap<>();
public static void initGameOptions(GameOptions options) {
// Messes with the consistency of gametests
options.tutorialStep = TutorialStep.NONE;
options.getCloudRenderMode().setValue(CloudRenderMode.OFF);
// Messes with game tests starting
options.onboardAccessibility = false;
// Just annoying
options.getSoundVolumeOption(SoundCategory.MUSIC).setValue(0.0);
((GameOptionsAccessor) options).invokeAccept(new GameOptions.Visitor() {
@Override
public int visitInt(String key, int current) {
DEFAULT_GAME_OPTIONS.put(key, current);
return current;
}
@Override
public boolean visitBoolean(String key, boolean current) {
DEFAULT_GAME_OPTIONS.put(key, current);
return current;
}
@Override
public String visitString(String key, String current) {
DEFAULT_GAME_OPTIONS.put(key, current);
return current;
}
@Override
public float visitFloat(String key, float current) {
DEFAULT_GAME_OPTIONS.put(key, current);
return current;
}
@Override
public <T> T visitObject(String key, T current, Function<String, T> decoder, Function<T, String> encoder) {
DEFAULT_GAME_OPTIONS.put(key, current);
return current;
}
@Override
public <T> void accept(String key, SimpleOption<T> option) {
DEFAULT_GAME_OPTIONS.put(key, option.getValue());
}
});
}
@Override
public void waitTick() {
ThreadingImpl.checkOnGametestThread("waitTick");
ThreadingImpl.runTick();
}
@Override
public void waitTicks(int ticks) {
ThreadingImpl.checkOnGametestThread("waitTicks");
Preconditions.checkArgument(ticks >= 0, "ticks cannot be negative");
for (int i = 0; i < ticks; i++) {
ThreadingImpl.runTick();
}
}
@Override
public void waitFor(Predicate<MinecraftClient> predicate) {
ThreadingImpl.checkOnGametestThread("waitFor");
Preconditions.checkNotNull(predicate, "predicate");
waitFor(predicate, DEFAULT_TIMEOUT);
}
@Override
public void waitFor(Predicate<MinecraftClient> predicate, int timeout) {
ThreadingImpl.checkOnGametestThread("waitFor");
Preconditions.checkNotNull(predicate, "predicate");
if (timeout == NO_TIMEOUT) {
while (!computeOnClient(predicate::test)) {
ThreadingImpl.runTick();
}
} else {
Preconditions.checkArgument(timeout > 0, "timeout must be positive");
for (int i = 0; i < timeout; i++) {
if (computeOnClient(predicate::test)) {
return;
}
ThreadingImpl.runTick();
}
if (!computeOnClient(predicate::test)) {
throw new AssertionError("Timed out waiting for predicate");
}
}
}
@Override
public void waitForScreen(@Nullable Class<? extends Screen> screenClass) {
ThreadingImpl.checkOnGametestThread("waitForScreen");
if (screenClass == null) {
waitFor(client -> client.currentScreen == null);
} else {
waitFor(client -> screenClass.isInstance(client.currentScreen));
}
}
@Override
public void setScreen(Supplier<@Nullable Screen> screen) {
ThreadingImpl.checkOnGametestThread("setScreen");
runOnClient(client -> client.setScreen(screen.get()));
}
@Override
public void clickScreenButton(String translationKey) {
ThreadingImpl.checkOnGametestThread("clickScreenButton");
Preconditions.checkNotNull(translationKey, "translationKey");
runOnClient(client -> {
if (!tryClickScreenButtonImpl(client.currentScreen, translationKey)) {
throw new AssertionError("Could not find button '%s' in screen '%s'".formatted(
translationKey,
Nullables.map(client.currentScreen, screen -> screen.getClass().getName())
));
}
});
}
@Override
public boolean tryClickScreenButton(String translationKey) {
ThreadingImpl.checkOnGametestThread("tryClickScreenButton");
Preconditions.checkNotNull(translationKey, "translationKey");
return computeOnClient(client -> tryClickScreenButtonImpl(client.currentScreen, translationKey));
}
private static boolean tryClickScreenButtonImpl(@Nullable Screen screen, String translationKey) {
if (screen == null) {
return false;
}
final String buttonText = Text.translatable(translationKey).getString();
final ScreenAccessor screenAccessor = (ScreenAccessor) screen;
for (Drawable drawable : screenAccessor.getDrawables()) {
if (drawable instanceof PressableWidget pressableWidget && pressMatchingButton(pressableWidget, buttonText)) {
return true;
}
if (drawable instanceof Widget widget) {
MutableBoolean found = new MutableBoolean(false);
widget.forEachChild(clickableWidget -> {
if (!found.booleanValue()) {
found.setValue(pressMatchingButton(clickableWidget, buttonText));
}
});
if (found.booleanValue()) {
return true;
}
}
}
// Was unable to find the button to press
return false;
}
private static boolean pressMatchingButton(ClickableWidget widget, String text) {
if (widget instanceof ButtonWidget buttonWidget) {
if (text.equals(buttonWidget.getMessage().getString())) {
buttonWidget.onPress();
return true;
}
}
if (widget instanceof CyclingButtonWidget<?> buttonWidget) {
CyclingButtonWidgetAccessor accessor = (CyclingButtonWidgetAccessor) buttonWidget;
if (text.equals(accessor.getOptionText().getString())) {
buttonWidget.onPress();
return true;
}
}
return false;
}
@Override
public Path takeScreenshot(String name) {
ThreadingImpl.checkOnGametestThread("takeScreenshot");
Preconditions.checkNotNull(name, "name");
return takeScreenshot(name, 1);
}
@Override
public Path takeScreenshot(String name, int delay) {
ThreadingImpl.checkOnGametestThread("takeScreenshot");
Preconditions.checkNotNull(name, "name");
Preconditions.checkArgument(delay >= 0, "delay cannot be negative");
waitTicks(delay);
runOnClient(client -> {
ScreenshotRecorder.saveScreenshot(FabricLoader.getInstance().getGameDir().toFile(), name + ".png", client.getFramebuffer(), (message) -> {
});
});
return FabricLoader.getInstance().getGameDir().resolve("screenshots").resolve(name + ".png");
}
@Override
public ClientGameTestInputImpl getInput() {
return input;
}
@Override
public void restoreDefaultGameOptions() {
ThreadingImpl.checkOnGametestThread("restoreDefaultGameOptions");
runOnClient(client -> {
((GameOptionsAccessor) MinecraftClient.getInstance().options).invokeAccept(new GameOptions.Visitor() {
@Override
public int visitInt(String key, int current) {
return (Integer) DEFAULT_GAME_OPTIONS.get(key);
}
@Override
public boolean visitBoolean(String key, boolean current) {
return (Boolean) DEFAULT_GAME_OPTIONS.get(key);
}
@Override
public String visitString(String key, String current) {
return (String) DEFAULT_GAME_OPTIONS.get(key);
}
@Override
public float visitFloat(String key, float current) {
return (Float) DEFAULT_GAME_OPTIONS.get(key);
}
@SuppressWarnings("unchecked")
@Override
public <T> T visitObject(String key, T current, Function<String, T> decoder, Function<T, String> encoder) {
return (T) DEFAULT_GAME_OPTIONS.get(key);
}
@SuppressWarnings("unchecked")
@Override
public <T> void accept(String key, SimpleOption<T> option) {
option.setValue((T) DEFAULT_GAME_OPTIONS.get(key));
}
});
});
}
@Override
public <E extends Throwable> void runOnClient(FailableConsumer<MinecraftClient, E> action) throws E {
ThreadingImpl.checkOnGametestThread("runOnClient");
Preconditions.checkNotNull(action, "action");
ThreadingImpl.runOnClient(() -> action.accept(MinecraftClient.getInstance()));
}
@Override
public <T, E extends Throwable> T computeOnClient(FailableFunction<MinecraftClient, T, E> function) throws E {
ThreadingImpl.checkOnGametestThread("computeOnClient");
Preconditions.checkNotNull(function, "function");
MutableObject<T> result = new MutableObject<>();
ThreadingImpl.runOnClient(() -> result.setValue(function.apply(MinecraftClient.getInstance())));
return result.getValue();
}
}

View file

@ -0,0 +1,333 @@
/*
* 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.fabricmc.fabric.impl.client.gametest;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Function;
import com.google.common.base.Preconditions;
import org.lwjgl.glfw.GLFW;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.option.GameOptions;
import net.minecraft.client.option.KeyBinding;
import net.minecraft.client.util.InputUtil;
import net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestInput;
import net.fabricmc.fabric.mixin.client.gametest.KeyBindingAccessor;
import net.fabricmc.fabric.mixin.client.gametest.KeyboardAccessor;
import net.fabricmc.fabric.mixin.client.gametest.MouseAccessor;
public final class ClientGameTestInputImpl implements ClientGameTestInput {
private static final Set<InputUtil.Key> KEYS_DOWN = new HashSet<>();
private final ClientGameTestContext context;
public ClientGameTestInputImpl(ClientGameTestContext context) {
this.context = context;
}
public static boolean isKeyDown(int keyCode) {
return KEYS_DOWN.contains(InputUtil.Type.KEYSYM.createFromCode(keyCode));
}
public void clearKeysDown() {
for (InputUtil.Key key : new ArrayList<>(KEYS_DOWN)) {
releaseKey(key);
}
}
@Override
public void holdKey(KeyBinding keyBinding) {
ThreadingImpl.checkOnGametestThread("holdKey");
Preconditions.checkNotNull(keyBinding, "keyBinding");
holdKey(getBoundKey(keyBinding, "hold"));
}
@Override
public void holdKey(Function<GameOptions, KeyBinding> keyBindingGetter) {
ThreadingImpl.checkOnGametestThread("holdKey");
Preconditions.checkNotNull(keyBindingGetter, "keyBindingGetter");
KeyBinding keyBinding = context.computeOnClient(client -> keyBindingGetter.apply(client.options));
holdKey(keyBinding);
}
@Override
public void holdKey(InputUtil.Key key) {
ThreadingImpl.checkOnGametestThread("holdKey");
Preconditions.checkNotNull(key, "key");
if (KEYS_DOWN.add(key)) {
context.runOnClient(client -> pressOrReleaseKey(client, key, GLFW.GLFW_PRESS));
}
}
@Override
public void holdKey(int keyCode) {
ThreadingImpl.checkOnGametestThread("holdKey");
holdKey(InputUtil.Type.KEYSYM.createFromCode(keyCode));
}
@Override
public void holdMouse(int button) {
ThreadingImpl.checkOnGametestThread("holdMouse");
holdKey(InputUtil.Type.MOUSE.createFromCode(button));
}
@Override
public void holdControl() {
ThreadingImpl.checkOnGametestThread("holdControl");
holdKey(MinecraftClient.IS_SYSTEM_MAC ? InputUtil.GLFW_KEY_LEFT_SUPER : InputUtil.GLFW_KEY_LEFT_CONTROL);
}
@Override
public void holdShift() {
ThreadingImpl.checkOnGametestThread("holdShift");
holdKey(InputUtil.GLFW_KEY_LEFT_SHIFT);
}
@Override
public void holdAlt() {
ThreadingImpl.checkOnGametestThread("holdAlt");
holdKey(InputUtil.GLFW_KEY_LEFT_ALT);
}
@Override
public void releaseKey(KeyBinding keyBinding) {
ThreadingImpl.checkOnGametestThread("releaseKey");
Preconditions.checkNotNull(keyBinding, "keyBinding");
releaseKey(getBoundKey(keyBinding, "release"));
}
@Override
public void releaseKey(Function<GameOptions, KeyBinding> keyBindingGetter) {
ThreadingImpl.checkOnGametestThread("releaseKey");
Preconditions.checkNotNull(keyBindingGetter, "keyBindingGetter");
KeyBinding keyBinding = context.computeOnClient(client -> keyBindingGetter.apply(client.options));
releaseKey(keyBinding);
}
@Override
public void releaseKey(InputUtil.Key key) {
ThreadingImpl.checkOnGametestThread("releaseKey");
Preconditions.checkNotNull(key, "key");
if (KEYS_DOWN.remove(key)) {
context.runOnClient(client -> pressOrReleaseKey(client, key, GLFW.GLFW_RELEASE));
}
}
@Override
public void releaseKey(int keyCode) {
ThreadingImpl.checkOnGametestThread("releaseKey");
releaseKey(InputUtil.Type.KEYSYM.createFromCode(keyCode));
}
@Override
public void releaseMouse(int button) {
ThreadingImpl.checkOnGametestThread("releaseMouse");
releaseKey(InputUtil.Type.MOUSE.createFromCode(button));
}
@Override
public void releaseControl() {
ThreadingImpl.checkOnGametestThread("releaseControl");
releaseKey(MinecraftClient.IS_SYSTEM_MAC ? InputUtil.GLFW_KEY_LEFT_SUPER : InputUtil.GLFW_KEY_LEFT_CONTROL);
}
@Override
public void releaseShift() {
ThreadingImpl.checkOnGametestThread("releaseShift");
releaseKey(InputUtil.GLFW_KEY_LEFT_SHIFT);
}
@Override
public void releaseAlt() {
ThreadingImpl.checkOnGametestThread("releaseAlt");
releaseKey(InputUtil.GLFW_KEY_LEFT_ALT);
}
private static void pressOrReleaseKey(MinecraftClient client, InputUtil.Key key, int action) {
switch (key.getCategory()) {
case KEYSYM -> client.keyboard.onKey(client.getWindow().getHandle(), key.getCode(), 0, action, 0);
case SCANCODE -> client.keyboard.onKey(client.getWindow().getHandle(), GLFW.GLFW_KEY_UNKNOWN, key.getCode(), action, 0);
case MOUSE -> ((MouseAccessor) client.mouse).invokeOnMouseButton(client.getWindow().getHandle(), key.getCode(), action, 0);
}
}
@Override
public void pressKey(KeyBinding keyBinding) {
ThreadingImpl.checkOnGametestThread("pressKey");
Preconditions.checkNotNull(keyBinding, "keyBinding");
pressKey(getBoundKey(keyBinding, "press"));
}
@Override
public void pressKey(Function<GameOptions, KeyBinding> keyBindingGetter) {
ThreadingImpl.checkOnGametestThread("pressKey");
Preconditions.checkNotNull(keyBindingGetter, "keyBindingGetter");
KeyBinding keyBinding = context.computeOnClient(client -> keyBindingGetter.apply(client.options));
pressKey(keyBinding);
}
@Override
public void pressKey(InputUtil.Key key) {
ThreadingImpl.checkOnGametestThread("pressKey");
Preconditions.checkNotNull(key, "key");
holdKey(key);
releaseKey(key);
}
@Override
public void pressKey(int keyCode) {
ThreadingImpl.checkOnGametestThread("pressKey");
pressKey(InputUtil.Type.KEYSYM.createFromCode(keyCode));
}
@Override
public void pressMouse(int button) {
ThreadingImpl.checkOnGametestThread("pressMouse");
pressKey(InputUtil.Type.MOUSE.createFromCode(button));
}
@Override
public void holdKeyFor(KeyBinding keyBinding, int ticks) {
ThreadingImpl.checkOnGametestThread("holdKeyFor");
Preconditions.checkNotNull(keyBinding, "keyBinding");
Preconditions.checkArgument(ticks > 0, "ticks must be positive");
holdKeyFor(getBoundKey(keyBinding, "hold"), ticks);
}
@Override
public void holdKeyFor(Function<GameOptions, KeyBinding> keyBindingGetter, int ticks) {
ThreadingImpl.checkOnGametestThread("holdKeyFor");
Preconditions.checkNotNull(keyBindingGetter, "keyBindingGetter");
Preconditions.checkArgument(ticks > 0, "ticks must be positive");
KeyBinding keyBinding = context.computeOnClient(client -> keyBindingGetter.apply(client.options));
holdKeyFor(keyBinding, ticks);
}
@Override
public void holdKeyFor(InputUtil.Key key, int ticks) {
ThreadingImpl.checkOnGametestThread("holdKeyFor");
Preconditions.checkNotNull(key, "key");
Preconditions.checkArgument(ticks > 0, "ticks must be positive");
holdKey(key);
context.waitTicks(ticks);
releaseKey(key);
}
@Override
public void holdKeyFor(int keyCode, int ticks) {
ThreadingImpl.checkOnGametestThread("holdKeyFor");
Preconditions.checkArgument(ticks > 0, "ticks must be positive");
holdKeyFor(InputUtil.Type.KEYSYM.createFromCode(keyCode), ticks);
}
@Override
public void holdMouseFor(int button, int ticks) {
ThreadingImpl.checkOnGametestThread("holdMouseFor");
Preconditions.checkArgument(ticks > 0, "ticks must be positive");
holdKeyFor(InputUtil.Type.MOUSE.createFromCode(button), ticks);
}
@Override
public void typeChar(int codePoint) {
ThreadingImpl.checkOnGametestThread("typeChar");
context.runOnClient(client -> ((KeyboardAccessor) client.keyboard).invokeOnChar(client.getWindow().getHandle(), codePoint, 0));
}
@Override
public void typeChars(String chars) {
ThreadingImpl.checkOnGametestThread("typeChars");
context.runOnClient(client -> {
chars.chars().forEach(codePoint -> {
((KeyboardAccessor) client.keyboard).invokeOnChar(client.getWindow().getHandle(), codePoint, 0);
});
});
}
@Override
public void scroll(double amount) {
ThreadingImpl.checkOnGametestThread("scroll");
scroll(0, amount);
}
@Override
public void scroll(double xAmount, double yAmount) {
ThreadingImpl.checkOnGametestThread("scroll");
context.runOnClient(client -> ((MouseAccessor) client.mouse).invokeOnMouseScroll(client.getWindow().getHandle(), xAmount, yAmount));
}
@Override
public void setCursorPos(double x, double y) {
ThreadingImpl.checkOnGametestThread("setCursorPos");
context.runOnClient(client -> ((MouseAccessor) client.mouse).invokeOnCursorPos(client.getWindow().getHandle(), x, y));
}
@Override
public void moveCursor(double deltaX, double deltaY) {
ThreadingImpl.checkOnGametestThread("moveCursor");
context.runOnClient(client -> {
double newX = client.mouse.getX() + deltaX;
double newY = client.mouse.getY() + deltaY;
((MouseAccessor) client.mouse).invokeOnCursorPos(client.getWindow().getHandle(), newX, newY);
});
}
private static InputUtil.Key getBoundKey(KeyBinding keyBinding, String action) {
InputUtil.Key boundKey = ((KeyBindingAccessor) keyBinding).getBoundKey();
if (boundKey == InputUtil.UNKNOWN_KEY) {
throw new AssertionError("Cannot %s binding '%s' because it isn't bound to a key".formatted(action, keyBinding.getTranslationKey()));
}
return boundKey;
}
}

View file

@ -0,0 +1,59 @@
/*
* 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.fabricmc.fabric.impl.client.gametest;
import java.util.List;
import java.util.Set;
import org.objectweb.asm.tree.ClassNode;
import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
public class ClientGameTestMixinConfigPlugin implements IMixinConfigPlugin {
private static final boolean ENABLED = System.getProperty("fabric.client.gametest") != null;
@Override
public void onLoad(String mixinPackage) {
}
@Override
public String getRefMapperConfig() {
return null;
}
@Override
public boolean shouldApplyMixin(String targetClassName, String mixinClassName) {
return ENABLED;
}
@Override
public void acceptTargets(Set<String> myTargets, Set<String> otherTargets) {
}
@Override
public List<String> getMixins() {
return null;
}
@Override
public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
}
@Override
public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
}
}

View file

@ -0,0 +1,70 @@
/*
* 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.fabricmc.fabric.impl.client.gametest;
import java.util.List;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.screen.TitleScreen;
import net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.loader.api.FabricLoader;
public class FabricClientGameTestRunner {
private static final String ENTRYPOINT_KEY = "fabric-client-gametest";
public static void start() {
// make the game think the window is focused
MinecraftClient.getInstance().onWindowFocusChanged(true);
List<FabricClientGameTest> gameTests = FabricLoader.getInstance().getEntrypoints(ENTRYPOINT_KEY, FabricClientGameTest.class);
ThreadingImpl.runTestThread(() -> {
ClientGameTestContextImpl context = new ClientGameTestContextImpl();
for (FabricClientGameTest gameTest : gameTests) {
context.restoreDefaultGameOptions();
try {
gameTest.runTest(context);
} finally {
context.getInput().clearKeysDown();
checkFinalGameTestState(context, gameTest.getClass().getName());
}
}
context.clickScreenButton("menu.quit");
});
}
private static void checkFinalGameTestState(ClientGameTestContext context, String testClassName) {
if (ThreadingImpl.isServerRunning) {
throw new AssertionError("Client gametest %s finished while a server is still running".formatted(testClassName));
}
context.runOnClient(client -> {
if (client.getNetworkHandler() != null) {
throw new AssertionError("Client gametest %s finished while still connected to a server".formatted(testClassName));
}
if (!(client.currentScreen instanceof TitleScreen)) {
throw new AssertionError("Client gametest %s did not finish on the title screen".formatted(testClassName));
}
});
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package net.fabricmc.fabric.test.base.client;
package net.fabricmc.fabric.impl.client.gametest;
import java.io.Closeable;
import java.io.IOException;

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package net.fabricmc.fabric.test.base.client;
package net.fabricmc.fabric.impl.client.gametest;
import java.util.concurrent.Phaser;
import java.util.concurrent.Semaphore;
@ -23,6 +23,8 @@ import com.google.common.base.Preconditions;
import org.apache.commons.lang3.function.FailableRunnable;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* <h1>Implementation notes</h1>
@ -64,6 +66,8 @@ public final class ThreadingImpl {
private ThreadingImpl() {
}
private static final Logger LOGGER = LoggerFactory.getLogger("fabric-client-gametest-api-v1");
public static final int PHASE_TICK = 0;
public static final int PHASE_SERVER_TASKS = 1;
public static final int PHASE_CLIENT_TASKS = 2;
@ -105,7 +109,7 @@ public final class ThreadingImpl {
try {
test.run();
} catch (Throwable e) {
e.printStackTrace();
LOGGER.error("Failed to run client gametests", e);
System.exit(1);
} finally {
PHASER.arriveAndDeregister();
@ -125,10 +129,14 @@ public final class ThreadingImpl {
testThread.start();
}
public static void checkOnGametestThread(String methodName) {
Preconditions.checkState(Thread.currentThread() == testThread, "%s can only be called from the client gametest thread", methodName);
}
@SuppressWarnings("unchecked")
public static <E extends Throwable> void runOnClient(FailableRunnable<E> action) throws E {
Preconditions.checkNotNull(action, "action");
Preconditions.checkState(Thread.currentThread() == testThread, "runOnClient can only be called from the test thread");
checkOnGametestThread("runOnClient");
Preconditions.checkState(clientCanAcceptTasks, "runOnClient called when no client is running");
MutableObject<E> thrown = new MutableObject<>();
@ -159,7 +167,7 @@ public final class ThreadingImpl {
@SuppressWarnings("unchecked")
public static <E extends Throwable> void runOnServer(FailableRunnable<E> action) throws E {
Preconditions.checkNotNull(action, "action");
Preconditions.checkState(Thread.currentThread() == testThread, "runOnServer can only be called from the test thread");
checkOnGametestThread("runOnServer");
Preconditions.checkState(serverCanAcceptTasks, "runOnServer called when no server is running");
MutableObject<E> thrown = new MutableObject<>();
@ -188,7 +196,7 @@ public final class ThreadingImpl {
}
public static void runTick() {
Preconditions.checkState(Thread.currentThread() == testThread, "runTick can only be called from the test thread");
checkOnGametestThread("runTick");
if (clientCanAcceptTasks) {
CLIENT_SEMAPHORE.release();

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package net.fabricmc.fabric.test.base.client.mixin;
package net.fabricmc.fabric.mixin.client.gametest;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;

View file

@ -0,0 +1,28 @@
/*
* 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.fabricmc.fabric.mixin.client.gametest;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Invoker;
import net.minecraft.client.option.GameOptions;
@Mixin(GameOptions.class)
public interface GameOptionsAccessor {
@Invoker
void invokeAccept(GameOptions.Visitor visitor);
}

View file

@ -0,0 +1,34 @@
/*
* 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.fabricmc.fabric.mixin.client.gametest;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import net.minecraft.client.option.GameOptions;
import net.fabricmc.fabric.impl.client.gametest.ClientGameTestContextImpl;
@Mixin(GameOptions.class)
public class GameOptionsMixin {
@Inject(method = "<init>", at = @At("RETURN"))
private void onCreateGameOptions(CallbackInfo ci) {
ClientGameTestContextImpl.initGameOptions((GameOptions) (Object) this);
}
}

View file

@ -0,0 +1,45 @@
/*
* 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.fabricmc.fabric.mixin.client.gametest;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import net.minecraft.client.util.InputUtil;
import net.fabricmc.fabric.impl.client.gametest.ClientGameTestInputImpl;
@Mixin(InputUtil.class)
public class InputUtilMixin {
@Inject(method = "isKeyPressed", at = @At("HEAD"), cancellable = true)
private static void useGameTestInputForKeyPressed(long window, int keyCode, CallbackInfoReturnable<Boolean> cir) {
cir.setReturnValue(ClientGameTestInputImpl.isKeyDown(keyCode));
}
@Inject(method = {"setKeyboardCallbacks", "setMouseCallbacks"}, at = @At("HEAD"), cancellable = true)
private static void dontAttachCallbacks(CallbackInfo ci) {
ci.cancel();
}
@Inject(method = "setCursorParameters", at = @At("HEAD"), cancellable = true)
private static void disableCursorLocking(CallbackInfo ci) {
ci.cancel();
}
}

View file

@ -0,0 +1,29 @@
/*
* 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.fabricmc.fabric.mixin.client.gametest;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
import net.minecraft.client.option.KeyBinding;
import net.minecraft.client.util.InputUtil;
@Mixin(KeyBinding.class)
public interface KeyBindingAccessor {
@Accessor
InputUtil.Key getBoundKey();
}

View file

@ -0,0 +1,28 @@
/*
* 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.fabricmc.fabric.mixin.client.gametest;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Invoker;
import net.minecraft.client.Keyboard;
@Mixin(Keyboard.class)
public interface KeyboardAccessor {
@Invoker
void invokeOnChar(long window, int codePoint, int modifiers);
}

View file

@ -14,12 +14,14 @@
* limitations under the License.
*/
package net.fabricmc.fabric.test.base.client.mixin;
package net.fabricmc.fabric.mixin.client.gametest;
import com.google.common.base.Preconditions;
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import org.jetbrains.annotations.Nullable;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
@ -27,86 +29,93 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.screen.Overlay;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.resource.ResourcePackManager;
import net.minecraft.server.SaveLoader;
import net.minecraft.world.level.storage.LevelStorage;
import net.fabricmc.fabric.test.base.client.FabricApiAutoTestClient;
import net.fabricmc.fabric.test.base.client.ThreadingImpl;
import net.fabricmc.fabric.impl.client.gametest.FabricClientGameTestRunner;
import net.fabricmc.fabric.impl.client.gametest.ThreadingImpl;
@Mixin(MinecraftClient.class)
public class MinecraftClientMixin {
@Unique
private boolean startedClientGametests = false;
@Unique
private Runnable deferredTask = null;
@Shadow
@Nullable
private Overlay overlay;
@WrapMethod(method = "run")
private void onRun(Operation<Void> original) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
if (ThreadingImpl.isClientRunning) {
throw new IllegalStateException("Client is already running");
}
ThreadingImpl.isClientRunning = true;
ThreadingImpl.PHASER.register();
if (ThreadingImpl.isClientRunning) {
throw new IllegalStateException("Client is already running");
}
ThreadingImpl.isClientRunning = true;
ThreadingImpl.PHASER.register();
try {
original.call();
} finally {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
ThreadingImpl.clientCanAcceptTasks = false;
ThreadingImpl.PHASER.arriveAndDeregister();
ThreadingImpl.isClientRunning = false;
}
ThreadingImpl.clientCanAcceptTasks = false;
ThreadingImpl.PHASER.arriveAndDeregister();
ThreadingImpl.isClientRunning = false;
}
}
@Inject(method = "tick", at = @At("HEAD"))
private void onTick(CallbackInfo ci) {
if (!startedClientGametests && overlay == null) {
startedClientGametests = true;
FabricClientGameTestRunner.start();
}
}
@Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;runTasks()V"))
private void preRunTasks(CallbackInfo ci) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_SERVER_TASKS);
// server tasks happen here
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_CLIENT_TASKS);
}
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_SERVER_TASKS);
// server tasks happen here
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_CLIENT_TASKS);
}
@Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;runTasks()V", shift = At.Shift.AFTER))
private void postRunTasks(CallbackInfo ci) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
ThreadingImpl.clientCanAcceptTasks = true;
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TEST);
ThreadingImpl.clientCanAcceptTasks = true;
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TEST);
if (ThreadingImpl.testThread != null) {
while (true) {
try {
ThreadingImpl.CLIENT_SEMAPHORE.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (ThreadingImpl.testThread != null) {
while (true) {
try {
ThreadingImpl.CLIENT_SEMAPHORE.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (ThreadingImpl.taskToRun != null) {
ThreadingImpl.taskToRun.run();
} else {
break;
}
if (ThreadingImpl.taskToRun != null) {
ThreadingImpl.taskToRun.run();
} else {
break;
}
}
}
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TICK);
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TICK);
Runnable deferredTask = this.deferredTask;
this.deferredTask = null;
Runnable deferredTask = this.deferredTask;
this.deferredTask = null;
if (deferredTask != null) {
deferredTask.run();
}
if (deferredTask != null) {
deferredTask.run();
}
}
@Inject(method = "startIntegratedServer", at = @At("HEAD"), cancellable = true)
private void deferStartIntegratedServer(LevelStorage.Session session, ResourcePackManager dataPackManager, SaveLoader saveLoader, boolean newWorld, CallbackInfo ci) {
if (FabricApiAutoTestClient.IS_AUTO_TEST && ThreadingImpl.taskToRun != null) {
if (ThreadingImpl.taskToRun != null) {
// don't start the integrated server (which busywaits) inside a task
deferredTask = () -> MinecraftClient.getInstance().startIntegratedServer(session, dataPackManager, saveLoader, newWorld);
ci.cancel();
@ -115,16 +124,14 @@ public class MinecraftClientMixin {
@Inject(method = "startIntegratedServer", at = @At(value = "INVOKE", target = "Ljava/lang/Thread;sleep(J)V", remap = false))
private void onStartIntegratedServerBusyWait(CallbackInfo ci) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
// give the server a chance to tick too
preRunTasks(ci);
postRunTasks(ci);
}
// give the server a chance to tick too
preRunTasks(ci);
postRunTasks(ci);
}
@Inject(method = "disconnect(Lnet/minecraft/client/gui/screen/Screen;Z)V", at = @At("HEAD"), cancellable = true)
private void deferDisconnect(Screen disconnectionScreen, boolean transferring, CallbackInfo ci) {
if (FabricApiAutoTestClient.IS_AUTO_TEST && MinecraftClient.getInstance().getServer() != null && ThreadingImpl.taskToRun != null) {
if (MinecraftClient.getInstance().getServer() != null && ThreadingImpl.taskToRun != null) {
// don't disconnect (which busywaits) inside a task
deferredTask = () -> MinecraftClient.getInstance().disconnect(disconnectionScreen, transferring);
ci.cancel();
@ -133,18 +140,16 @@ public class MinecraftClientMixin {
@Inject(method = "disconnect(Lnet/minecraft/client/gui/screen/Screen;Z)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;render(Z)V", shift = At.Shift.AFTER))
private void onDisconnectBusyWait(CallbackInfo ci) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
// give the server a chance to tick too
preRunTasks(ci);
postRunTasks(ci);
}
// give the server a chance to tick too
preRunTasks(ci);
postRunTasks(ci);
}
@Inject(method = "getInstance", at = @At("HEAD"))
private static void checkThreadOnGetInstance(CallbackInfoReturnable<MinecraftClient> cir) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
// TODO: add suggestion of runOnClient etc when API methods are added
Preconditions.checkState(Thread.currentThread() != ThreadingImpl.testThread, "MinecraftClient.getInstance() cannot be called from the test thread");
}
Preconditions.checkState(
Thread.currentThread() != ThreadingImpl.testThread,
"MinecraftClient.getInstance() cannot be called from the gametest thread. Try using ClientGameTestContext.runOnClient or ClientGameTestContext.computeOnClient"
);
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package net.fabricmc.fabric.test.base.client.mixin;
package net.fabricmc.fabric.mixin.client.gametest;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
@ -25,7 +25,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import net.minecraft.server.dedicated.MinecraftDedicatedServer;
import net.fabricmc.fabric.test.base.client.TestDedicatedServer;
import net.fabricmc.fabric.impl.client.gametest.TestDedicatedServer;
@Mixin(MinecraftDedicatedServer.class)
public abstract class MinecraftDedicatedServerMixin {

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package net.fabricmc.fabric.test.base.client.mixin;
package net.fabricmc.fabric.mixin.client.gametest;
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
@ -25,66 +25,57 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import net.minecraft.server.MinecraftServer;
import net.fabricmc.fabric.test.base.client.FabricApiAutoTestClient;
import net.fabricmc.fabric.test.base.client.ThreadingImpl;
import net.fabricmc.fabric.impl.client.gametest.ThreadingImpl;
@Mixin(MinecraftServer.class)
public class MinecraftServerMixin {
@WrapMethod(method = "runServer")
private void onRunServer(Operation<Void> original) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
if (ThreadingImpl.isServerRunning) {
throw new IllegalStateException("Server is already running");
}
ThreadingImpl.isServerRunning = true;
ThreadingImpl.PHASER.register();
if (ThreadingImpl.isServerRunning) {
throw new IllegalStateException("Server is already running");
}
ThreadingImpl.isServerRunning = true;
ThreadingImpl.PHASER.register();
try {
original.call();
} finally {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
ThreadingImpl.serverCanAcceptTasks = false;
ThreadingImpl.PHASER.arriveAndDeregister();
ThreadingImpl.isServerRunning = false;
}
ThreadingImpl.serverCanAcceptTasks = false;
ThreadingImpl.PHASER.arriveAndDeregister();
ThreadingImpl.isServerRunning = false;
}
}
@Inject(method = "runServer", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;runTasksTillTickEnd()V"))
private void preRunTasks(CallbackInfo ci) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_SERVER_TASKS);
}
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_SERVER_TASKS);
}
@Inject(method = "runServer", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;runTasksTillTickEnd()V", shift = At.Shift.AFTER))
private void postRunTasks(CallbackInfo ci) {
if (FabricApiAutoTestClient.IS_AUTO_TEST) {
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_CLIENT_TASKS);
// client tasks happen here
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_CLIENT_TASKS);
// client tasks happen here
ThreadingImpl.serverCanAcceptTasks = true;
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TEST);
ThreadingImpl.serverCanAcceptTasks = true;
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TEST);
if (ThreadingImpl.testThread != null) {
while (true) {
try {
ThreadingImpl.SERVER_SEMAPHORE.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (ThreadingImpl.testThread != null) {
while (true) {
try {
ThreadingImpl.SERVER_SEMAPHORE.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (ThreadingImpl.taskToRun != null) {
ThreadingImpl.taskToRun.run();
} else {
break;
}
if (ThreadingImpl.taskToRun != null) {
ThreadingImpl.taskToRun.run();
} else {
break;
}
}
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TICK);
}
ThreadingImpl.enterPhase(ThreadingImpl.PHASE_TICK);
}
}

View file

@ -0,0 +1,34 @@
/*
* 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.fabricmc.fabric.mixin.client.gametest;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Invoker;
import net.minecraft.client.Mouse;
@Mixin(Mouse.class)
public interface MouseAccessor {
@Invoker
void invokeOnMouseButton(long window, int button, int action, int mods);
@Invoker
void invokeOnMouseScroll(long window, double horizontal, double vertical);
@Invoker
void invokeOnCursorPos(long window, double x, double y);
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package net.fabricmc.fabric.test.base.client.mixin;
package net.fabricmc.fabric.mixin.client.gametest;
import java.util.List;

View file

@ -0,0 +1,32 @@
/*
* 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.fabricmc.fabric.mixin.client.gametest;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import net.minecraft.client.util.Window;
@Mixin(Window.class)
public class WindowMixin {
@Inject(method = {"onWindowFocusChanged", "onCursorEnterChanged"}, at = @At("HEAD"), cancellable = true)
private void cancelEvents(CallbackInfo ci) {
ci.cancel();
}
}

View file

@ -0,0 +1,2 @@
accessWidener v2 named
accessible class net/minecraft/client/option/GameOptions$Visitor

View file

@ -0,0 +1,23 @@
{
"required": true,
"package": "net.fabricmc.fabric.mixin.client.gametest",
"compatibilityLevel": "JAVA_21",
"mixins": [
"CyclingButtonWidgetAccessor",
"GameOptionsAccessor",
"GameOptionsMixin",
"InputUtilMixin",
"KeyBindingAccessor",
"KeyboardAccessor",
"MinecraftClientMixin",
"MinecraftDedicatedServerMixin",
"MinecraftServerMixin",
"MouseAccessor",
"ScreenAccessor",
"WindowMixin"
],
"plugin": "net.fabricmc.fabric.impl.client.gametest.ClientGameTestMixinConfigPlugin",
"injectors": {
"defaultRequire": 1
}
}

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"id": "fabric-client-gametest-api-v1",
"name": "Fabric Client Game Test API (v1)",
"version": "${version}",
"environment": "client",
"license": "Apache-2.0",
"icon": "assets/fabric-client-gametest-api-v1/icon.png",
"contact": {
"homepage": "https://fabricmc.net",
"irc": "irc://irc.esper.net:6667/fabric",
"issues": "https://github.com/FabricMC/fabric/issues",
"sources": "https://github.com/FabricMC/fabric"
},
"authors": [
"FabricMC"
],
"depends": {
"fabricloader": ">=0.16.9",
"fabric-resource-loader-v0": "*"
},
"description": "Allows registration of client game tests.",
"mixins": [
"fabric-client-gametest-api-v1.mixins.json"
],
"accessWidener": "fabric-client-gametest-api-v1.accesswidener",
"custom": {
"fabric-api:module-lifecycle": "experimental"
}
}

View file

@ -0,0 +1,192 @@
/*
* 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.fabricmc.fabric.test.client.gametest;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import com.mojang.authlib.GameProfile;
import org.spongepowered.asm.mixin.MixinEnvironment;
import net.minecraft.SharedConstants;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.screen.ConfirmScreen;
import net.minecraft.client.gui.screen.GameMenuScreen;
import net.minecraft.client.gui.screen.ReconfiguringScreen;
import net.minecraft.client.gui.screen.TitleScreen;
import net.minecraft.client.gui.screen.multiplayer.ConnectScreen;
import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen;
import net.minecraft.client.gui.screen.world.CreateWorldScreen;
import net.minecraft.client.gui.screen.world.LevelLoadingScreen;
import net.minecraft.client.gui.screen.world.SelectWorldScreen;
import net.minecraft.client.network.ServerAddress;
import net.minecraft.client.network.ServerInfo;
import net.minecraft.client.option.Perspective;
import net.minecraft.client.util.InputUtil;
import net.fabricmc.fabric.api.client.gametest.v1.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.impl.client.gametest.TestDedicatedServer;
import net.fabricmc.fabric.impl.client.gametest.ThreadingImpl;
import net.fabricmc.fabric.test.client.gametest.mixin.TitleScreenAccessor;
import net.fabricmc.loader.api.FabricLoader;
public class ClientGameTestTest implements FabricClientGameTest {
public void runTest(ClientGameTestContext context) {
{
waitForTitleScreenFade(context);
context.takeScreenshot("title_screen", 0);
context.clickScreenButton("menu.singleplayer");
}
if (!isDirEmpty(FabricLoader.getInstance().getGameDir().resolve("saves"))) {
context.waitForScreen(SelectWorldScreen.class);
context.takeScreenshot("select_world_screen");
context.clickScreenButton("selectWorld.create");
}
{
context.waitForScreen(CreateWorldScreen.class);
context.clickScreenButton("selectWorld.gameMode");
context.clickScreenButton("selectWorld.gameMode");
context.takeScreenshot("create_world_screen");
context.clickScreenButton("selectWorld.create");
}
{
// API test mods use experimental features
context.waitForScreen(ConfirmScreen.class);
context.clickScreenButton("gui.yes");
}
{
enableDebugHud(context);
waitForWorldTicks(context, 200);
context.takeScreenshot("in_game_overworld", 0);
}
{
context.getInput().pressKey(options -> options.chatKey);
context.waitTick();
context.getInput().typeChars("Hello, World!");
context.getInput().pressKey(InputUtil.GLFW_KEY_ENTER);
context.takeScreenshot("chat_message_sent", 5);
}
MixinEnvironment.getCurrentEnvironment().audit();
{
// See if the player render events are working.
setPerspective(context, Perspective.THIRD_PERSON_BACK);
context.takeScreenshot("in_game_overworld_third_person");
setPerspective(context, Perspective.FIRST_PERSON);
}
{
context.getInput().pressKey(options -> options.inventoryKey);
context.takeScreenshot("in_game_inventory");
context.setScreen(() -> null);
}
{
context.setScreen(() -> new GameMenuScreen(true));
context.takeScreenshot("game_menu");
context.clickScreenButton("menu.returnToMenu");
context.waitForScreen(TitleScreen.class);
waitForServerStop(context);
}
try (var server = new TestDedicatedServer()) {
connectToServer(context, server);
waitForWorldTicks(context, 5);
final GameProfile profile = context.computeOnClient(MinecraftClient::getGameProfile);
server.runCommand("op " + profile.getName());
server.runCommand("gamemode creative " + profile.getName());
waitForWorldTicks(context, 20);
context.takeScreenshot("server_in_game", 0);
{ // Test that we can enter and exit configuration
server.runCommand("debugconfig config " + profile.getName());
context.waitForScreen(ReconfiguringScreen.class);
context.takeScreenshot("server_config");
server.runCommand("debugconfig unconfig " + profile.getId());
waitForWorldTicks(context, 1);
}
context.setScreen(() -> new GameMenuScreen(true));
context.takeScreenshot("server_game_menu");
context.clickScreenButton("menu.disconnect");
context.waitForScreen(MultiplayerScreen.class);
context.clickScreenButton("gui.back");
}
{
context.waitForScreen(TitleScreen.class);
context.clickScreenButton("menu.quit");
}
}
private static boolean isDirEmpty(Path path) {
try (DirectoryStream<Path> directory = Files.newDirectoryStream(path)) {
return !directory.iterator().hasNext();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void waitForTitleScreenFade(ClientGameTestContext context) {
context.waitFor(client -> {
return !(client.currentScreen instanceof TitleScreenAccessor titleScreen) || !titleScreen.getDoBackgroundFade();
});
}
private static void enableDebugHud(ClientGameTestContext context) {
context.runOnClient(client -> client.inGameHud.getDebugHud().toggleDebugHud());
}
private static void setPerspective(ClientGameTestContext context, Perspective perspective) {
context.runOnClient(client -> client.options.setPerspective(perspective));
}
// TODO: replace with world builder
private static void waitForWorldTicks(ClientGameTestContext context, long ticks) {
// Wait for the world to be loaded and get the start ticks
context.waitFor(client -> client.world != null && !(client.currentScreen instanceof LevelLoadingScreen), 30 * SharedConstants.TICKS_PER_MINUTE);
final long startTicks = context.computeOnClient(client -> client.world.getTime());
context.waitFor(client -> Objects.requireNonNull(client.world).getTime() > startTicks + ticks, 10 * SharedConstants.TICKS_PER_MINUTE);
}
// TODO: replace with function on TestDedicatedServer
private static void connectToServer(ClientGameTestContext context, TestDedicatedServer server) {
context.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);
});
}
// TODO: move into close methods of TestDedicatedServer and TestWorld
private static void waitForServerStop(ClientGameTestContext context) {
context.waitFor(client -> !ThreadingImpl.isServerRunning, SharedConstants.TICKS_PER_MINUTE);
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package net.fabricmc.fabric.test.base.client.mixin;
package net.fabricmc.fabric.test.client.gametest.mixin;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;

View file

@ -0,0 +1,14 @@
{
"required": true,
"package": "net.fabricmc.fabric.test.client.gametest.mixin",
"compatibilityLevel": "JAVA_21",
"mixins": [
],
"plugin": "net.fabricmc.fabric.impl.client.gametest.ClientGameTestMixinConfigPlugin",
"injectors": {
"defaultRequire": 1
},
"client": [
"TitleScreenAccessor"
]
}

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"id": "fabric-client-gametest-api-v1-testmod",
"name": "Fabric Client Game Test API (v1) Test Mod",
"version": "1.0.0",
"environment": "*",
"license": "Apache-2.0",
"entrypoints": {
"fabric-client-gametest": [
"net.fabricmc.fabric.test.client.gametest.ClientGameTestTest"
]
},
"mixins": [
"fabric-client-gametest-api-v1-test.mixins.json"
]
}

View file

@ -17,6 +17,7 @@ fabric-biome-api-v1-version=15.0.5
fabric-block-api-v1-version=1.0.31
fabric-block-view-api-v2-version=1.0.19
fabric-blockrenderlayer-v1-version=2.0.7
fabric-client-gametest-api-v1-version=0.0.1
fabric-command-api-v1-version=1.2.61
fabric-command-api-v2-version=2.2.40
fabric-commands-v0-version=0.2.78

View file

@ -30,6 +30,7 @@ include 'fabric-biome-api-v1'
include 'fabric-block-api-v1'
include 'fabric-block-view-api-v2'
include 'fabric-blockrenderlayer-v1'
include 'fabric-client-gametest-api-v1'
include 'fabric-client-tags-api-v1'
include 'fabric-command-api-v2'
include 'fabric-content-registries-v0'