From 8272ba2230a71c26bafdc38f6fbc0dd5fc6c1508 Mon Sep 17 00:00:00 2001 From: Dogboy21 Date: Sun, 2 Jun 2024 00:23:20 +0200 Subject: [PATCH 1/3] Backported changes from the CC: Tweaked Gametest framework present in the 1.20.x branch This was mainly done to allow the execution of client tests --- .../gametest/core/CCTestCommand.java | 2 +- .../gametest/core/ClientHooks.java | 49 ----- .../gametest/core/MinecraftExtensions.java | 15 ++ .../computercraft/gametest/core/TestAPI.java | 24 ++- .../gametest/core/TestHooks.java | 57 ------ .../computercraft/gametest/core/TestMod.java | 77 ++----- .../mixin/gametest/GameTestSequenceMixin.java | 2 +- .../gametest/client/LevelSummaryMixin.java | 19 ++ .../mixin/gametest/client/MinecraftMixin.java | 55 +++++ .../gametest/client/WorldOpenFlowsMixin.java | 30 +++ .../gametest/api/ClientGameTest.kt | 33 +++ .../gametest/api/ClientTestExtensions.kt | 136 +++++++++++++ .../gametest/api/TestExtensions.kt | 4 +- .../computercraft/gametest/api/TestTags.kt | 20 ++ .../gametest/core/ClientTestHooks.kt | 189 ++++++++++++++++++ .../computercraft/gametest/core/TestHooks.kt | 143 +++++++++++++ .../gametest/core/TestReporters.kt | 48 +++++ src/testMod/resources/ccgametest.mixins.json | 5 + 18 files changed, 729 insertions(+), 179 deletions(-) delete mode 100644 src/testMod/java/dan200/computercraft/gametest/core/ClientHooks.java create mode 100644 src/testMod/java/dan200/computercraft/gametest/core/MinecraftExtensions.java delete mode 100644 src/testMod/java/dan200/computercraft/gametest/core/TestHooks.java create mode 100644 src/testMod/java/dan200/computercraft/mixin/gametest/client/LevelSummaryMixin.java create mode 100644 src/testMod/java/dan200/computercraft/mixin/gametest/client/MinecraftMixin.java create mode 100644 src/testMod/java/dan200/computercraft/mixin/gametest/client/WorldOpenFlowsMixin.java create mode 100644 src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt create mode 100644 src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt create mode 100644 src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt create mode 100644 src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt create mode 100644 src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt create mode 100644 src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt diff --git a/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java b/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java index 8c613eb13..b8d961bf3 100644 --- a/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java +++ b/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java @@ -107,7 +107,7 @@ private static Path getWorldComputerPath(MinecraftServer server) { } private static Path getSourceComputerPath() { - return TestHooks.sourceDir.resolve("computer"); + return TestHooks.getSourceDir().resolve("computer"); } private static int error(CommandSourceStack source, String message) { diff --git a/src/testMod/java/dan200/computercraft/gametest/core/ClientHooks.java b/src/testMod/java/dan200/computercraft/gametest/core/ClientHooks.java deleted file mode 100644 index 14170d26a..000000000 --- a/src/testMod/java/dan200/computercraft/gametest/core/ClientHooks.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * This file is part of ComputerCraft - http://www.computercraft.info - * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. - * Send enquiries to dratcliffe@gmail.com - */ -package dan200.computercraft.gametest.core; - -import net.minecraft.client.CloudStatus; -import net.minecraft.client.Minecraft; -import net.minecraft.client.ParticleStatus; -import net.minecraft.client.gui.screens.TitleScreen; -import net.minecraft.client.tutorial.TutorialSteps; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.client.event.ScreenEvent; -import net.minecraftforge.eventbus.api.SubscribeEvent; -import net.minecraftforge.fml.common.Mod; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -@Mod.EventBusSubscriber(modid = "cctest", value = Dist.CLIENT) -public final class ClientHooks { - private static final Logger LOG = LogManager.getLogger(TestHooks.class); - - private static boolean triggered = false; - - private ClientHooks() { - } - - @SubscribeEvent - public static void onGuiInit(ScreenEvent.Init event) { - if (triggered || !(event.getScreen() instanceof TitleScreen)) - return; - triggered = true; - - ClientHooks.openWorld(); - } - - private static void openWorld() { - Minecraft minecraft = Minecraft.getInstance(); - - // Clear some options before we get any further. - minecraft.options.autoJump().set(false); - minecraft.options.cloudStatus().set(CloudStatus.OFF); - minecraft.options.particles().set(ParticleStatus.MINIMAL); - minecraft.options.tutorialStep = TutorialSteps.NONE; - minecraft.options.renderDistance().set(6); - minecraft.options.gamma().set(1.0); - } -} diff --git a/src/testMod/java/dan200/computercraft/gametest/core/MinecraftExtensions.java b/src/testMod/java/dan200/computercraft/gametest/core/MinecraftExtensions.java new file mode 100644 index 000000000..1a21f8d9a --- /dev/null +++ b/src/testMod/java/dan200/computercraft/gametest/core/MinecraftExtensions.java @@ -0,0 +1,15 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.gametest.core; + +import net.minecraft.client.Minecraft; + +/** + * Extensions to {@link Minecraft}, injected via mixin. + */ +public interface MinecraftExtensions { + boolean computercraft$isRenderingStable(); +} diff --git a/src/testMod/java/dan200/computercraft/gametest/core/TestAPI.java b/src/testMod/java/dan200/computercraft/gametest/core/TestAPI.java index 5ea5e8cfc..7c5d12362 100644 --- a/src/testMod/java/dan200/computercraft/gametest/core/TestAPI.java +++ b/src/testMod/java/dan200/computercraft/gametest/core/TestAPI.java @@ -16,7 +16,10 @@ import de.srendi.advancedperipherals.common.util.LuaConverter; import net.minecraft.gametest.framework.GameTestSequence; import net.minecraftforge.server.ServerLifecycleHooks; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.annotation.Nullable; import java.util.Optional; /** @@ -27,8 +30,10 @@ * @see TestExtensionsKt#thenComputerOk(GameTestSequence, String, String) To check tests on the computer have passed. */ public class TestAPI extends ComputerState implements ILuaAPI { + private static final Logger LOG = LoggerFactory.getLogger(TestAPI.class); + private final IComputerSystem system; - private String label; + private @Nullable String label; TestAPI(IComputerSystem system) { this.system = system; @@ -39,10 +44,10 @@ public void startup() { if (label == null) label = system.getLabel(); if (label == null) { label = "#" + system.getID(); - ComputerCraft.log.warn("Computer {} has no label", label); + LOG.warn("Computer {} has no label", label); } - ComputerCraft.log.info("Computer '{}' has turned on.", label); + LOG.info("Computer '{}' has turned on.", label); markers.clear(); error = null; lookup.put(label, this); @@ -50,19 +55,18 @@ public void startup() { @Override public void shutdown() { - ComputerCraft.log.info("Computer '{}' has shut down.", label); - if (lookup.get(label) == this) - lookup.remove(label); + LOG.info("Computer '{}' has shut down.", label); + if (lookup.get(label) == this) lookup.remove(label); } @Override public String[] getNames() { - return new String[]{"test"}; + return new String[]{ "test" }; } @LuaFunction public final void fail(String message) throws LuaException { - ComputerCraft.log.error("Computer '{}' failed with {}", label, message); + LOG.error("Computer '{}' failed with {}", label, message); if (markers.contains(ComputerState.DONE)) throw new LuaException("Cannot call fail/ok multiple times."); markers.add(ComputerState.DONE); error = message; @@ -71,7 +75,7 @@ public final void fail(String message) throws LuaException { @LuaFunction public final void ok(Optional marker) throws LuaException { - String actualMarker = marker.orElse(ComputerState.DONE); + var actualMarker = marker.orElse(ComputerState.DONE); if (markers.contains(ComputerState.DONE) || markers.contains(actualMarker)) { throw new LuaException("Cannot call fail/ok multiple times."); } @@ -81,7 +85,7 @@ public final void ok(Optional marker) throws LuaException { @LuaFunction public final void log(String message) { - ComputerCraft.log.info("[Computer '{}'] {}", label, message); + LOG.info("[Computer '{}'] {}", label, message); } @LuaFunction diff --git a/src/testMod/java/dan200/computercraft/gametest/core/TestHooks.java b/src/testMod/java/dan200/computercraft/gametest/core/TestHooks.java deleted file mode 100644 index cf1164c52..000000000 --- a/src/testMod/java/dan200/computercraft/gametest/core/TestHooks.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * This file is part of ComputerCraft - http://www.computercraft.info - * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. - * Send enquiries to dratcliffe@gmail.com - */ -package dan200.computercraft.gametest.core; - -import dan200.computercraft.api.ComputerCraftAPI; -import dan200.computercraft.gametest.api.Times; -import dan200.computercraft.shared.computer.core.ServerContext; -import net.minecraft.core.BlockPos; -import net.minecraft.gametest.framework.GameTestRunner; -import net.minecraft.gametest.framework.GameTestTicker; -import net.minecraft.gametest.framework.StructureUtils; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.GameRules; -import net.minecraft.world.level.Level; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.file.Path; -import java.nio.file.Paths; - -public class TestHooks { - public static final Logger LOGGER = LoggerFactory.getLogger(TestHooks.class); - - public static final Path sourceDir = Paths.get(System.getProperty("advancedperipheralstest.sources")).normalize().toAbsolutePath(); - - public static void init() { - ServerContext.luaMachine = ManagedComputers.INSTANCE; - ComputerCraftAPI.registerAPIFactory(TestAPI::new); - StructureUtils.testStructuresDir = sourceDir.resolve("structures").toString(); - } - - public static void onServerStarted(MinecraftServer server) { - GameRules rules = server.getGameRules(); - rules.getRule(GameRules.RULE_DAYLIGHT).set(false, server); - - ServerLevel world = server.getLevel(Level.OVERWORLD); - if (world != null) - world.setDayTime(Times.NOON); - - LOGGER.info("Cleaning up after last run"); - GameTestRunner.clearAllTests(server.overworld(), new BlockPos(0, -60, 0), GameTestTicker.SINGLETON, 200); - - // Delete server context and add one with a mutable machine factory. This allows us to set the factory for - // specific test batches without having to reset all computers. - for (var computer : ServerContext.get(server).registry().getComputers()) { - var label = computer.getLabel() == null ? "#" + computer.getID() : computer.getLabel(); - LOGGER.warn("Unexpected computer {}", label); - } - - LOGGER.info("Importing files"); - CCTestCommand.importFiles(server); - } -} diff --git a/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java b/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java index 116ecd458..187f446a0 100644 --- a/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java +++ b/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java @@ -7,17 +7,16 @@ import dan200.computercraft.export.Exporter; import dan200.computercraft.gametest.api.GameTestHolder; -import net.minecraft.gametest.framework.GameTest; -import net.minecraft.gametest.framework.GameTestRegistry; -import net.minecraft.gametest.framework.StructureUtils; -import net.minecraft.gametest.framework.TestFunction; -import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.client.event.RegisterClientCommandsEvent; +import net.minecraftforge.client.event.ScreenEvent; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.RegisterCommandsEvent; import net.minecraftforge.event.RegisterGameTestsEvent; +import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.server.ServerStartedEvent; import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.fml.DistExecutor; import net.minecraftforge.fml.ModList; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; @@ -43,7 +42,7 @@ public TestMod() { var bus = MinecraftForge.EVENT_BUS; bus.addListener(EventPriority.LOW, (ServerStartedEvent e) -> TestHooks.onServerStarted(e.getServer())); bus.addListener((RegisterCommandsEvent e) -> CCTestCommand.register(e.getDispatcher())); - bus.addListener((RegisterClientCommandsEvent e) -> Exporter.register(e.getDispatcher())); + DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> TestMod::onInitializeClient); var modBus = FMLJavaModLoadingContext.get().getModEventBus(); modBus.addListener((RegisterGameTestsEvent event) -> { @@ -56,6 +55,17 @@ public TestMod() { }); } + private static void onInitializeClient() { + var bus = MinecraftForge.EVENT_BUS; + + bus.addListener((TickEvent.ServerTickEvent e) -> { + if (e.phase == TickEvent.Phase.START) ClientTestHooks.onServerTick(e.getServer()); + }); + bus.addListener((ScreenEvent.Opening e) -> { + if (ClientTestHooks.onOpenScreen(e.getScreen())) e.setCanceled(true); + }); + bus.addListener((RegisterClientCommandsEvent e) -> Exporter.register(e.getDispatcher())); + } private static Class loadClass(String name) { try { @@ -68,60 +78,7 @@ private static Class loadClass(String name) { private static void registerClass(String className, Consumer fallback) { var klass = loadClass(className); for (var method : klass.getDeclaredMethods()) { - var testInfo = method.getAnnotation(GameTest.class); - if (testInfo == null) { - fallback.accept(method); - continue; - } - - GameTestRegistry.getAllTestFunctions().add(turnMethodIntoTestFunction(method, testInfo)); - GameTestRegistry.getAllTestClassNames().add(className); + TestHooks.registerTest(klass, method, fallback); } } - - /** - * Custom implementation of {@link GameTestRegistry#turnMethodIntoTestFunction(Method)} which makes - * {@link GameTest#template()} behave the same as Fabric, namely in that it points to a {@link ResourceLocation}, - * rather than a test-class-specific structure. - *

- * This effectively acts as a global version of {@link PrefixGameTestTemplate}, just one which doesn't require Forge - * to be present. - * - * @param method The method to register. - * @param testInfo The test info. - * @return The constructed test function. - */ - private static TestFunction turnMethodIntoTestFunction(Method method, GameTest testInfo) { - var className = method.getDeclaringClass().getSimpleName().toLowerCase(Locale.ROOT); - var testName = className + "." + method.getName().toLowerCase(Locale.ROOT); - return new TestFunction( - testInfo.batch(), - testName, - testInfo.template().isEmpty() ? testName : testInfo.template(), - StructureUtils.getRotationForRotationSteps(testInfo.rotationSteps()), testInfo.timeoutTicks(), testInfo.setupTicks(), - testInfo.required(), testInfo.requiredSuccesses(), testInfo.attempts(), - turnMethodIntoConsumer(method) - ); - } - - private static Consumer turnMethodIntoConsumer(Method method) { - return value -> { - try { - Object instance = null; - if (!Modifier.isStatic(method.getModifiers())) { - instance = method.getDeclaringClass().getConstructor().newInstance(); - } - - method.invoke(instance, value); - } catch (InvocationTargetException e) { - if (e.getCause() instanceof RuntimeException) { - throw (RuntimeException) e.getCause(); - } else { - throw new RuntimeException(e.getCause()); - } - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } - }; - } } diff --git a/src/testMod/java/dan200/computercraft/mixin/gametest/GameTestSequenceMixin.java b/src/testMod/java/dan200/computercraft/mixin/gametest/GameTestSequenceMixin.java index bbc65b042..86f3e5d16 100644 --- a/src/testMod/java/dan200/computercraft/mixin/gametest/GameTestSequenceMixin.java +++ b/src/testMod/java/dan200/computercraft/mixin/gametest/GameTestSequenceMixin.java @@ -37,7 +37,7 @@ public void tickAndContinue(long ticks) { parent.fail(e); } catch (Exception e) { // Fail the test, rather than crashing the server. - TestHooks.LOGGER.error("{} threw unexpected exception", parent.getTestName(), e); + TestHooks.LOG.error("{} threw unexpected exception", parent.getTestName(), e); parent.fail(e); } } diff --git a/src/testMod/java/dan200/computercraft/mixin/gametest/client/LevelSummaryMixin.java b/src/testMod/java/dan200/computercraft/mixin/gametest/client/LevelSummaryMixin.java new file mode 100644 index 000000000..41b3548b8 --- /dev/null +++ b/src/testMod/java/dan200/computercraft/mixin/gametest/client/LevelSummaryMixin.java @@ -0,0 +1,19 @@ +package dan200.computercraft.mixin.gametest.client; + +import net.minecraft.world.level.storage.LevelSummary; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; + +/** + * Used to suppress the "Worlds using Experimental Settings are not supported" warning + * when loading a world in GameTest. + */ +@Mixin(LevelSummary.class) +public class LevelSummaryMixin { + + @Overwrite + public boolean isExperimental() { + return false; + } + +} diff --git a/src/testMod/java/dan200/computercraft/mixin/gametest/client/MinecraftMixin.java b/src/testMod/java/dan200/computercraft/mixin/gametest/client/MinecraftMixin.java new file mode 100644 index 000000000..141bf1525 --- /dev/null +++ b/src/testMod/java/dan200/computercraft/mixin/gametest/client/MinecraftMixin.java @@ -0,0 +1,55 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.mixin.gametest.client; + +import dan200.computercraft.gametest.core.MinecraftExtensions; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.LevelRenderer; +import org.spongepowered.asm.mixin.Final; +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; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import javax.annotation.Nullable; +import java.util.concurrent.atomic.AtomicBoolean; + +@Mixin(Minecraft.class) +class MinecraftMixin implements MinecraftExtensions { + @Final + @Shadow + public LevelRenderer levelRenderer; + + @Shadow + @Nullable + public ClientLevel level; + + @Shadow + @Nullable + public LocalPlayer player; + + @Unique + private final AtomicBoolean isStable = new AtomicBoolean(false); + + @Inject(method = "runTick", at = @At("TAIL")) + @SuppressWarnings("unused") + private void updateStable(boolean render, CallbackInfo ci) { + isStable.set( + level != null && player != null && + levelRenderer.isChunkCompiled(player.blockPosition()) && levelRenderer.countRenderedChunks() > 10 && + levelRenderer.hasRenderedAllChunks() + ); + } + + @Override + public boolean computercraft$isRenderingStable() { + return isStable.get(); + } +} diff --git a/src/testMod/java/dan200/computercraft/mixin/gametest/client/WorldOpenFlowsMixin.java b/src/testMod/java/dan200/computercraft/mixin/gametest/client/WorldOpenFlowsMixin.java new file mode 100644 index 000000000..c55a72e1c --- /dev/null +++ b/src/testMod/java/dan200/computercraft/mixin/gametest/client/WorldOpenFlowsMixin.java @@ -0,0 +1,30 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.mixin.gametest.client; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.worldselection.WorldOpenFlows; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; + +@Mixin(WorldOpenFlows.class) +public class WorldOpenFlowsMixin { + /** + * Never prompt for backup/experimental options when running tests. + * + * @param screen The current menu. + * @param level The level to load. + * @param customised Whether this rule uses legacy customised worldgen options. + * @param action The action run to load the world. + * @author SquidDev + * @reason Makes it easier to run tests. We can switch to an @Inject if this becomes a problem. + */ + @Overwrite + @SuppressWarnings("unused") + private void askForBackup(Screen screen, String level, boolean customised, Runnable action) { + action.run(); + } +} diff --git a/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt b/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt new file mode 100644 index 000000000..2527d3159 --- /dev/null +++ b/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt @@ -0,0 +1,33 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.gametest.api + +import net.minecraft.gametest.framework.GameTest + +/** + * Similar to [GameTest], this annotation defines a method which runs under Minecraft's gametest sequence. + * + * Unlike standard game tests, client game tests are only registered when running under the Minecraft client, and run + * sequentially rather than in parallel. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ClientGameTest( + /** + * The template to use for this test, identical to [GameTest.template] + */ + val template: String = "", + + /** + * The timeout for this test, identical to [GameTest.timeoutTicks]. + */ + val timeoutTicks: Int = Timeouts.DEFAULT, + + /** + * The tag associated with this test, denoting when it should run. + */ + val tag: String = TestTags.CLIENT, +) diff --git a/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt b/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt new file mode 100644 index 000000000..7c8147ab7 --- /dev/null +++ b/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt @@ -0,0 +1,136 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.gametest.api + +import dan200.computercraft.gametest.core.MinecraftExtensions +import dan200.computercraft.mixin.gametest.GameTestSequenceAccessor +import net.minecraft.client.Minecraft +import net.minecraft.client.Screenshot +import net.minecraft.client.gui.screens.inventory.MenuAccess +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.GameTestSequence +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.EntityType +import net.minecraft.world.inventory.AbstractContainerMenu +import net.minecraft.world.inventory.MenuType +import net.minecraftforge.registries.ForgeRegistries +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Attempt to guess whether all chunks have been rendered. + */ +fun Minecraft.isRenderingStable(): Boolean = (this as MinecraftExtensions).`computercraft$isRenderingStable`() + +/** + * Run a task on the client. + */ +fun GameTestSequence.thenOnClient(task: ClientTestHelper.() -> Unit): GameTestSequence { + var future: CompletableFuture? = null + thenExecute { future = Minecraft.getInstance().submit { task(ClientTestHelper()) } } + thenWaitUntil { if (!future!!.isDone) throw GameTestAssertException("Not done task yet") } + thenExecute { + try { + future!!.get() + } catch (e: ExecutionException) { + throw e.cause ?: e + } + } + return this +} + +/** + * Take a screenshot of the current game state. + */ +fun GameTestSequence.thenScreenshot(name: String? = null, showGui: Boolean = false): GameTestSequence { + val suffix = if (name == null) "" else "-$name" + val test = (this as GameTestSequenceAccessor).parent + val fullName = "${test.testName}$suffix" + + // Wait until all chunks have been rendered and we're idle for an extended period. + var counter = 0 + thenWaitUntil { + if (Minecraft.getInstance().isRenderingStable()) { + val idleFor = ++counter + if (idleFor <= 20) throw GameTestAssertException("Only idle for $idleFor ticks") + } else { + counter = 0 + throw GameTestAssertException("Waiting for client to finish rendering") + } + } + + // Now disable the GUI, take a screenshot and reenable it. Sleep a little afterwards to ensure the render thread + // has caught up. + thenOnClient { minecraft.options.hideGui = !showGui } + thenIdle(2) + + // Take a screenshot and wait for it to have finished. + val hasScreenshot = AtomicBoolean() + thenOnClient { screenshot("$fullName.png") { hasScreenshot.set(true) } } + thenWaitUntil { if (!hasScreenshot.get()) throw GameTestAssertException("Screenshot does not exist") } + thenOnClient { minecraft.options.hideGui = false } + + return this +} + +/** + * "Reset" the current player, ensuring. + */ +fun ServerPlayer.setupForTest() { + if (containerMenu != inventoryMenu) closeContainer() +} + +/** + * Position the player at an armor stand. + */ +fun GameTestHelper.positionAtArmorStand() { + val stand = getEntity(EntityType.ARMOR_STAND) + val player = level.randomPlayer ?: throw GameTestAssertException("Player does not exist") + + player.setupForTest() + player.connection.teleport(stand.x, stand.y, stand.z, stand.yRot, stand.xRot) +} + +/** + * Position the player at a given coordinate. + */ +fun GameTestHelper.positionAt(pos: BlockPos, yRot: Float = 0.0f, xRot: Float = 0.0f) { + val absolutePos = absolutePos(pos) + val player = level.randomPlayer ?: throw GameTestAssertException("Player does not exist") + + player.setupForTest() + player.connection.teleport(absolutePos.x + 0.5, absolutePos.y + 0.5, absolutePos.z + 0.5, yRot, xRot) +} + +/** + * The equivalent of a [GameTestHelper] on the client. + */ +class ClientTestHelper { + val minecraft: Minecraft = Minecraft.getInstance() + + fun screenshot(name: String, callback: () -> Unit = {}) { + Screenshot.grab(minecraft.gameDirectory, name, minecraft.mainRenderTarget) { callback() } + } + + /** + * Get the currently open [AbstractContainerMenu], ensuring it is of a specific type. + */ + fun getOpenMenu(type: MenuType): T { + fun getName(type: MenuType<*>) = ForgeRegistries.MENU_TYPES.getKey(type) + + val screen = minecraft.screen + @Suppress("UNCHECKED_CAST") + when { + screen == null -> throw GameTestAssertException("Expected a ${getName(type)} menu, but no screen is open") + screen !is MenuAccess<*> -> throw GameTestAssertException("Expected a ${getName(type)} menu, but a $screen is open") + screen.menu.type != type -> throw GameTestAssertException("Expected a ${getName(type)} menu, but a ${getName(screen.menu.type)} is open") + else -> return screen.menu as T + } + } +} diff --git a/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt b/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt index 6f776aee7..16874ee2f 100644 --- a/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt +++ b/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt @@ -46,7 +46,9 @@ object Times { * @see GameTest.timeoutTicks */ object Timeouts { - private const val SECOND: Int = 20 + const val SECOND: Int = 20 + + const val DEFAULT: Int = SECOND * 5 const val COMPUTER_TIMEOUT: Int = SECOND * 15 } diff --git a/src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt b/src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt new file mode 100644 index 000000000..a0b999d1c --- /dev/null +++ b/src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package dan200.computercraft.gametest.api + +/** + * "Tags" associated with each test, denoting whether a specific test should be registered for the current Minecraft + * session. + * + * This is used to only run some tests on the client, or when a specific mod is loaded. + */ +object TestTags { + const val COMMON = "common" + const val CLIENT = "client" + + private val tags: Set = System.getProperty("advancedperipheralstest.tags", COMMON).split(',').toSet() + + fun isEnabled(tag: String) = tags.contains(tag) +} diff --git a/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt b/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt new file mode 100644 index 000000000..e986197ee --- /dev/null +++ b/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt @@ -0,0 +1,189 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.gametest.core + +import dan200.computercraft.gametest.api.Timeouts +import dan200.computercraft.gametest.api.isRenderingStable +import dan200.computercraft.gametest.api.setupForTest +import net.minecraft.client.CloudStatus +import net.minecraft.client.Minecraft +import net.minecraft.client.ParticleStatus +import net.minecraft.client.gui.screens.AccessibilityOptionsScreen +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.TitleScreen +import net.minecraft.client.tutorial.TutorialSteps +import net.minecraft.core.BlockPos +import net.minecraft.core.Registry +import net.minecraft.core.RegistryAccess +import net.minecraft.gametest.framework.* +import net.minecraft.server.MinecraftServer +import net.minecraft.sounds.SoundSource +import net.minecraft.util.RandomSource +import net.minecraft.world.Difficulty +import net.minecraft.world.level.* +import net.minecraft.world.level.block.Rotation +import net.minecraft.world.level.levelgen.presets.WorldPresets +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import kotlin.system.exitProcess + +/** + * Client-side hooks for game tests. + * + * This mirrors Minecraft's + */ +object ClientTestHooks { + private val LOG: Logger = LoggerFactory.getLogger(ClientTestHooks::class.java) + + private const val LEVEL_NAME = "test" + + /** + * Time (in ticks) that we wait after the client joins the world + */ + private const val STARTUP_DELAY = 5 * Timeouts.SECOND + + /** + * Whether our client-side game test driver is enabled. + */ + private val enabled: Boolean = System.getProperty("advancedperipheralstest.client") != null + + private var loadedWorld: Boolean = false + + @JvmStatic + fun onOpenScreen(screen: Screen): Boolean = when { + enabled && !loadedWorld && (screen is TitleScreen || screen is AccessibilityOptionsScreen) -> { + loadedWorld = true + openWorld() + true + } + + else -> false + } + + /** + * Open or create our test world immediately on game launch. + */ + private fun openWorld() { + val minecraft = Minecraft.getInstance() + + // Clear some options before we get any further. + minecraft.options.autoJump().set(false) + minecraft.options.cloudStatus().set(CloudStatus.OFF) + minecraft.options.particles().set(ParticleStatus.MINIMAL) + minecraft.options.tutorialStep = TutorialSteps.NONE + minecraft.options.renderDistance().set(6) + minecraft.options.gamma().set(1.0) + minecraft.options.setSoundCategoryVolume(SoundSource.MUSIC, 0f) + minecraft.options.setSoundCategoryVolume(SoundSource.AMBIENT, 0f) + + if (minecraft.levelSource.levelExists(LEVEL_NAME)) { + LOG.info("World already exists, opening.") + minecraft.createWorldOpenFlows().loadLevel(minecraft.screen, LEVEL_NAME) + } else { + LOG.info("World does not exist, creating it.") + val rules = GameRules() + rules.getRule(GameRules.RULE_DOMOBSPAWNING).set(false, null) + rules.getRule(GameRules.RULE_DAYLIGHT).set(false, null) + rules.getRule(GameRules.RULE_WEATHER_CYCLE).set(false, null) + + val registryAccess = RegistryAccess.builtinCopy().freeze() + minecraft.createWorldOpenFlows().createFreshLevel( + LEVEL_NAME, + LevelSettings("Test Level", GameType.CREATIVE, false, Difficulty.EASY, true, rules, DataPackConfig.DEFAULT), + registryAccess, + registryAccess.registryOrThrow(Registry.WORLD_PRESET_REGISTRY).getHolderOrThrow(WorldPresets.FLAT).value().createWorldGenSettings(RandomSource.create().nextLong(), false, false) + ) + } + } + + private var testTracker: MultipleTestTracker? = null + private var hasFinished: Boolean = false + private var startupDelay: Int = STARTUP_DELAY + + @JvmStatic + fun onServerTick(server: MinecraftServer) { + if (!enabled || hasFinished) return + + val testTracker = when (val tracker = this.testTracker) { + null -> { + if (server.overworld().players().isEmpty()) return + if (!Minecraft.getInstance().isRenderingStable()) return + if (startupDelay >= 0) { + // TODO: Is there a better way? Maybe set a flag when the client starts rendering? + startupDelay-- + return + } + + LOG.info("Server ready, starting.") + + val tests = GameTestRunner.runTestBatches( + GameTestRunner.groupTestsIntoBatches(GameTestRegistry.getAllTestFunctions()), + BlockPos(0, -60, 0), + Rotation.NONE, + server.overworld(), + GameTestTicker.SINGLETON, + 8, + ) + val testTracker = MultipleTestTracker(tests) + testTracker.addListener( + object : GameTestListener { + fun testFinished() { + for (it in server.playerList.players) it.setupForTest() + } + + override fun testPassed(test: GameTestInfo) = testFinished() + override fun testFailed(test: GameTestInfo) = testFinished() + override fun testStructureLoaded(test: GameTestInfo) = Unit + }, + ) + + LOG.info("{} tests are now running!", testTracker.totalCount) + this.testTracker = testTracker + testTracker + } + + else -> tracker + } + + if (server.overworld().gameTime % 20L == 0L) LOG.info(testTracker.progressBar) + + if (testTracker.isDone) { + hasFinished = true + LOG.info(testTracker.progressBar) + + GlobalTestReporter.finish() + LOG.info("========= {} GAME TESTS COMPLETE ======================", testTracker.totalCount) + if (testTracker.hasFailedRequired()) { + LOG.info("{} required tests failed :(", testTracker.failedRequiredCount) + for (test in testTracker.failedRequired) LOG.info(" - {}", test.testName) + } else { + LOG.info("All {} required tests passed :)", testTracker.totalCount) + } + if (testTracker.hasFailedOptional()) { + LOG.info("{} optional tests failed", testTracker.failedOptionalCount) + for (test in testTracker.failedOptional) LOG.info(" - {}", test.testName) + } + LOG.info("====================================================") + + // Stop Minecraft *from the client thread*. We need to do this to avoid deadlocks in stopping the server. + val minecraft = Minecraft.getInstance() + minecraft.execute { + LOG.info("Stopping client.") + minecraft.level!!.disconnect() + minecraft.clearLevel() + minecraft.stop() + + exitProcess( + when { + testTracker.totalCount == 0 -> 1 + testTracker.hasFailedRequired() -> 2 + else -> 0 + }, + ) + } + } + } +} diff --git a/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt b/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt new file mode 100644 index 000000000..ab1c77de6 --- /dev/null +++ b/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt @@ -0,0 +1,143 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.gametest.core + +import dan200.computercraft.api.ComputerCraftAPI +import dan200.computercraft.gametest.* +import dan200.computercraft.gametest.api.ClientGameTest +import dan200.computercraft.gametest.api.TestTags +import dan200.computercraft.gametest.api.Times +import dan200.computercraft.shared.computer.core.ServerContext +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.* +import net.minecraft.server.MinecraftServer +import net.minecraft.world.level.GameRules +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.nio.file.Path +import java.nio.file.Paths +import java.util.function.Consumer +import javax.xml.parsers.ParserConfigurationException + +object TestHooks { + @JvmField + val LOG: Logger = LoggerFactory.getLogger(TestHooks::class.java) + + @JvmStatic + val sourceDir: Path = Paths.get(System.getProperty("advancedperipheralstest.sources")).normalize().toAbsolutePath() + + @JvmStatic + fun init() { + ServerContext.luaMachine = ManagedComputers + ComputerCraftAPI.registerAPIFactory(::TestAPI) + StructureUtils.testStructuresDir = sourceDir.resolve("structures").toString() + + // Set up our test reporter if configured. + val outputPath = System.getProperty("advancedperipheralstest.gametest-report") + if (outputPath != null) { + try { + GlobalTestReporter.replaceWith( + MultiTestReporter( + JunitTestReporter(File(outputPath)), + LogTestReporter(), + ), + ) + } catch (e: ParserConfigurationException) { + throw RuntimeException(e) + } + } + } + + @JvmStatic + fun onServerStarted(server: MinecraftServer) { + val rules = server.gameRules + rules.getRule(GameRules.RULE_DAYLIGHT).set(false, server) + server.overworld().dayTime = Times.NOON + + LOG.info("Cleaning up after last run") + GameTestRunner.clearAllTests(server.overworld(), BlockPos(0, -60, 0), GameTestTicker.SINGLETON, 200) + + // Delete server context and add one with a mutable machine factory. This allows us to set the factory for + // specific test batches without having to reset all computers. + for (computer in ServerContext.get(server).registry().computers) { + val label = if (computer.label == null) "#" + computer.id else computer.label!! + LOG.warn("Unexpected computer {}", label) + } + + LOG.info("Importing files") + CCTestCommand.importFiles(server) + } + + private val isCi = System.getenv("CI") != null + + /** + * Adjust the timeout of a test. This makes it 1.5 times longer when run under CI, as CI servers are less powerful + * than our own. + */ + private fun adjustTimeout(timeout: Int): Int = if (isCi) timeout + (timeout / 2) else timeout + + @JvmStatic + fun registerTest(testClass: Class<*>, method: Method, fallbackRegister: Consumer) { + val className = testClass.simpleName.lowercase() + val testName = className + "." + method.name.lowercase() + + method.getAnnotation(GameTest::class.java)?.let { testInfo -> + if (!TestTags.isEnabled(TestTags.COMMON)) return + + GameTestRegistry.getAllTestFunctions().add( + TestFunction( + testInfo.batch, testName, testInfo.template.ifEmpty { testName }, + StructureUtils.getRotationForRotationSteps(testInfo.rotationSteps), + adjustTimeout(testInfo.timeoutTicks), + testInfo.setupTicks, + testInfo.required, testInfo.requiredSuccesses, testInfo.attempts, + ) { value -> safeInvoke(method, value) }, + ) + GameTestRegistry.getAllTestClassNames().add(testClass.simpleName) + return + } + + method.getAnnotation(ClientGameTest::class.java)?.let { testInfo -> + if (!TestTags.isEnabled(testInfo.tag)) return + + GameTestRegistry.getAllTestFunctions().add( + TestFunction( + testName, + testName, + testInfo.template.ifEmpty { testName }, + adjustTimeout(testInfo.timeoutTicks), + 0, + true, + ) { value -> safeInvoke(method, value) }, + ) + GameTestRegistry.getAllTestClassNames().add(testClass.simpleName) + return + } + + fallbackRegister.accept(method) + } + + private fun safeInvoke(method: Method, value: Any) { + try { + var instance: Any? = null + if (!Modifier.isStatic(method.modifiers)) { + instance = method.declaringClass.getConstructor().newInstance() + } + method.invoke(instance, value) + } catch (e: InvocationTargetException) { + when (val cause = e.cause) { + is RuntimeException -> throw cause + else -> throw RuntimeException(cause) + } + } catch (e: ReflectiveOperationException) { + throw RuntimeException(e) + } + } +} diff --git a/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt b/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt new file mode 100644 index 000000000..4d9d4bf89 --- /dev/null +++ b/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt @@ -0,0 +1,48 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.gametest.core + +import net.minecraft.gametest.framework.GameTestInfo +import net.minecraft.gametest.framework.JUnitLikeTestReporter +import net.minecraft.gametest.framework.TestReporter +import java.io.File +import java.io.IOException +import java.nio.file.Files +import javax.xml.transform.TransformerException + +/** + * A test reporter which delegates to a list of other reporters. + */ +class MultiTestReporter(private val reporters: List) : TestReporter { + constructor(vararg reporters: TestReporter) : this(listOf(*reporters)) + + override fun onTestFailed(test: GameTestInfo) { + for (reporter in reporters) reporter.onTestFailed(test) + } + + override fun onTestSuccess(test: GameTestInfo) { + for (reporter in reporters) reporter.onTestSuccess(test) + } + + override fun finish() { + for (reporter in reporters) reporter.finish() + } +} + +/** + * Reports tests to a JUnit XML file. This is equivalent to [JUnitLikeTestReporter], except it ensures the destination + * directory exists. + */ +class JunitTestReporter constructor(destination: File) : JUnitLikeTestReporter(destination) { + override fun save(file: File) { + try { + Files.createDirectories(file.toPath().parent) + } catch (e: IOException) { + throw TransformerException("Failed to create parent directory", e) + } + super.save(file) + } +} diff --git a/src/testMod/resources/ccgametest.mixins.json b/src/testMod/resources/ccgametest.mixins.json index 76ac77997..299086dc9 100644 --- a/src/testMod/resources/ccgametest.mixins.json +++ b/src/testMod/resources/ccgametest.mixins.json @@ -12,5 +12,10 @@ "GameTestSequenceAccessor", "GameTestSequenceMixin", "TestCommandAccessor" + ], + "client": [ + "client.MinecraftMixin", + "client.WorldOpenFlowsMixin", + "client.LevelSummaryMixin" ] } From bc4d3e470cdd78835403df9730e912eff1269c8a Mon Sep 17 00:00:00 2001 From: Dogboy21 Date: Sun, 2 Jun 2024 01:31:48 +0200 Subject: [PATCH 2/3] Add gradle task to start automated client tests --- build.gradle | 11 +- buildSrc/build.gradle.kts | 21 ++ .../kotlin/cc/tweaked/gradle/Extensions.kt | 157 +++++++++++++ .../cc/tweaked/gradle/ForgeExtensions.kt | 26 +++ .../kotlin/cc/tweaked/gradle/MinecraftExec.kt | 215 ++++++++++++++++++ .../cc/tweaked/gradle/ProcessHelpers.kt | 77 +++++++ .../gradle/common/util/runs/RunConfigSetup.kt | 48 ++++ .../gametest/core/ClientTestHooks.kt | 2 +- 8 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/cc/tweaked/gradle/Extensions.kt create mode 100644 buildSrc/src/main/kotlin/cc/tweaked/gradle/ForgeExtensions.kt create mode 100644 buildSrc/src/main/kotlin/cc/tweaked/gradle/MinecraftExec.kt create mode 100644 buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt create mode 100644 buildSrc/src/main/kotlin/net/minecraftforge/gradle/common/util/runs/RunConfigSetup.kt diff --git a/build.gradle b/build.gradle index d2d3e930d..a24ec0d47 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,4 @@ +import cc.tweaked.gradle.ClientJavaExec import net.darkhax.curseforgegradle.TaskPublishCurseForge import java.text.SimpleDateFormat @@ -8,7 +9,7 @@ plugins { id 'org.jetbrains.changelog' version '1.2.1' id "com.modrinth.minotaur" version "2.+" id "org.jetbrains.kotlin.jvm" version "${kotlin_version}" - id 'net.minecraftforge.gradle' version '[6.0.18,6.2)' + id 'net.minecraftforge.gradle' id 'org.parchmentmc.librarian.forgegradle' version '1.+' id 'org.spongepowered.mixin' version '0.7.+' id "com.github.breadmoirai.github-release" version "2.5.2" @@ -148,7 +149,7 @@ minecraft { } } - gameTestClient { + testClient { workingDirectory project.file('test-files/client') parent runs.client @@ -664,3 +665,9 @@ publishing { } } } + +tasks.register('runGameTestClient', ClientJavaExec, { task -> + description "Runs client-side gametests with no mods" + setRunConfig(minecraft.runs["testClient"]) + tags("client") +}) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 000000000..d2d420771 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + `java-gradle-plugin` + `kotlin-dsl` + kotlin("jvm") version "1.9.23" +} + +repositories { + mavenCentral() + + maven("https://maven.minecraftforge.net") { + name = "Forge" + content { + includeGroup("net.minecraftforge") + includeGroup("net.minecraftforge.gradle") + } + } +} + +dependencies { + implementation("net.minecraftforge.gradle:ForgeGradle:[6.0.18,6.2)") +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/Extensions.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/Extensions.kt new file mode 100644 index 000000000..8afc4f0f3 --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/Extensions.kt @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.gradle + +import org.gradle.api.artifacts.dsl.DependencyHandler +import org.gradle.api.file.FileSystemLocation +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.JavaExec +import org.gradle.process.BaseExecSpec +import org.gradle.process.JavaExecSpec +import org.gradle.process.ProcessForkOptions + +/** + * Add an annotation processor to all source sets. + */ +fun DependencyHandler.annotationProcessorEverywhere(dep: Any) { + add("compileOnly", dep) + add("annotationProcessor", dep) + + add("clientCompileOnly", dep) + add("clientAnnotationProcessor", dep) + + add("testCompileOnly", dep) + add("testAnnotationProcessor", dep) +} + +/** + * A version of [JavaExecSpec.copyTo] which copies *all* properties. + */ +fun JavaExec.copyToFull(spec: JavaExec) { + copyTo(spec) + + // Additional Java options + spec.jvmArgs = jvmArgs // Fabric overrides getJvmArgs so copyTo doesn't do the right thing. + spec.args = args + spec.argumentProviders.addAll(argumentProviders) + spec.mainClass.set(mainClass) + spec.classpath = classpath + spec.javaLauncher.set(javaLauncher) + if (executable != null) spec.setExecutable(executable!!) + + // Additional ExecSpec options + copyToExec(spec) +} + +/** + * Copy additional [BaseExecSpec] options which aren't handled by [ProcessForkOptions.copyTo]. + */ +fun BaseExecSpec.copyToExec(spec: BaseExecSpec) { + spec.isIgnoreExitValue = isIgnoreExitValue + if (standardInput != null) spec.standardInput = standardInput + if (standardOutput != null) spec.standardOutput = standardOutput + if (errorOutput != null) spec.errorOutput = errorOutput +} + +/** + * An alternative to [Nothing] with a more descriptive name. Use to enforce calling a function with named arguments: + * + * ```kotlin + * fun f(vararg unused: UseNamedArgs, arg1: Int, arg2: Int) { + * // ... + * } + * ``` + */ +class UseNamedArgs private constructor() + +/** + * An [AutoCloseable] implementation which can be used to combine other [AutoCloseable] instances. + * + * Values which implement [AutoCloseable] can be dynamically registered with [CloseScope.add]. When the scope is closed, + * each value is closed in the opposite order. + * + * This is largely intended for cases where it's not appropriate to nest [AutoCloseable.use], for instance when nested + * would be too deep. + */ +class CloseScope : AutoCloseable { + private val toClose = ArrayDeque() + + /** + * Add a value to be closed when this scope is closed. + */ + public fun add(value: AutoCloseable) { + toClose.addLast(value) + } + + override fun close() { + close(null) + } + + @PublishedApi + internal fun close(baseException: Throwable?) { + var exception = baseException + + while (true) { + var toClose = toClose.removeLastOrNull() ?: break + try { + toClose.close() + } catch (e: Throwable) { + if (exception == null) { + exception = e + } else { + exception.addSuppressed(e) + } + } + } + + if (exception != null) throw exception + } + + inline fun use(block: (CloseScope) -> R): R { + var exception: Throwable? = null + try { + return block(this) + } catch (e: Throwable) { + exception = e + throw e + } finally { + close(exception) + } + } +} + +/** Proxy method to avoid overload ambiguity. */ +fun Property.setProvider(provider: Provider) = set(provider) + +/** Short-cut method to get the absolute path of a [FileSystemLocation] provider. */ +fun Provider.getAbsolutePath(): String = get().asFile.absolutePath + +/** + * Get the version immediately after the provided version. + * + * For example, given "1.2.3", this will return "1.2.4". + */ +fun getNextVersion(version: String): String { + // Split a version like x.y.z-SNAPSHOT into x.y.z and -SNAPSHOT + val dashIndex = version.indexOf('-') + val mainVersion = if (dashIndex < 0) version else version.substring(0, dashIndex) + + // Find the last component in x.y.z and increment it. + val lastIndex = mainVersion.lastIndexOf('.') + if (lastIndex < 0) throw IllegalArgumentException("Cannot parse version format \"$version\"") + val lastVersion = try { + version.substring(lastIndex + 1).toInt() + } catch (e: NumberFormatException) { + throw IllegalArgumentException("Cannot parse version format \"$version\"", e) + } + + // Then append all components together. + val out = StringBuilder() + out.append(version, 0, lastIndex + 1) + out.append(lastVersion + 1) + if (dashIndex >= 0) out.append(version, dashIndex, version.length) + return out.toString() +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/ForgeExtensions.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/ForgeExtensions.kt new file mode 100644 index 000000000..bbf6e86c5 --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/ForgeExtensions.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.gradle + +import net.minecraftforge.gradle.common.util.RunConfig +import net.minecraftforge.gradle.common.util.runs.setRunConfigInternal +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.JavaExec +import org.gradle.jvm.toolchain.JavaToolchainService +import java.nio.file.Files + +/** + * Set [JavaExec] task to run a given [RunConfig]. + */ +fun JavaExec.setRunConfig(config: RunConfig) { + dependsOn("prepareRuns") + setRunConfigInternal(project, this, config) + doFirst("Create working directory") { Files.createDirectories(workingDir.toPath()) } + + javaLauncher.set( + project.extensions.getByType(JavaToolchainService::class.java) + .launcherFor(project.extensions.getByType(JavaPluginExtension::class.java).toolchain), + ) +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/MinecraftExec.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/MinecraftExec.kt new file mode 100644 index 000000000..daf382ab0 --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/MinecraftExec.kt @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.gradle + +import net.minecraftforge.gradle.common.util.RunConfig +import org.gradle.api.GradleException +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.invocation.Gradle +import org.gradle.api.provider.Provider +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters +import org.gradle.api.tasks.* +import org.gradle.kotlin.dsl.getByName +import org.gradle.language.base.plugins.LifecycleBasePlugin +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import java.util.function.Supplier +import javax.inject.Inject +import kotlin.random.Random + +/** + * A [JavaExec] task for client-tests. This sets some common setup, and uses [MinecraftRunnerService] to ensure only one + * test runs at once. + */ +abstract class ClientJavaExec : JavaExec() { + private val clientRunner: Provider = MinecraftRunnerService.get(project.gradle) + + init { + group = LifecycleBasePlugin.VERIFICATION_GROUP + usesService(clientRunner) + } + + @get:Input + val renderdoc get() = project.hasProperty("renderdoc") + + /** + * When [false], tests will not be run automatically, allowing the user to debug rendering. + */ + @get:Input + val clientDebug get() = renderdoc || project.hasProperty("clientDebug") + + /** + * When [false], tests will not run under a framebuffer. + */ + @get:Input + val useFramebuffer get() = !clientDebug && !project.hasProperty("clientNoFramebuffer") + + /** + * The path test results are written to. + */ + @get:OutputFile + val testResults = project.layout.buildDirectory.file("test-results/$name.xml") + + private fun setTestProperties() { + if (!clientDebug) systemProperty("advancedperipheralstest.client", "") + if (renderdoc) environment("LD_PRELOAD", "/usr/lib/librenderdoc.so") + systemProperty("advancedperipheralstest.gametest-report", testResults.get().asFile.absoluteFile) + workingDir(project.layout.buildDirectory.dir("gametest/$name")) + } + + init { + setTestProperties() + } + + /** + * Set this task to run a given [RunConfig]. + */ + fun setRunConfig(config: RunConfig) { + (this as JavaExec).setRunConfig(config) + setTestProperties() // setRunConfig may clobber some properties, ensure everything is set. + } + + /** + * Copy configuration from a task with the given name. + */ + fun copyFrom(path: String) = copyFrom(project.tasks.getByName(path, JavaExec::class)) + + /** + * Copy configuration from an existing [JavaExec] task. + */ + fun copyFrom(task: JavaExec) { + for (dep in task.dependsOn) dependsOn(dep) + task.copyToFull(this) + setTestProperties() // copyToFull may clobber some properties, ensure everything is set. + } + + /** + * Only run tests with the given tags. + */ + fun tags(vararg tags: String) { + systemProperty("advancedperipheralstest.tags", tags.joinToString(",")) + } + + /** + * Write a file with the given contents before starting Minecraft. This may be useful for writing config files. + */ + fun withFileContents(path: Any, contents: Supplier) { + val file = project.file(path).toPath() + doFirst { + Files.createDirectories(file.parent) + Files.writeString(file, contents.get()) + } + } + + /** + * Copy a file to the provided path before starting Minecraft. This copy only occurs if the file does not already + * exist. + */ + fun withFileFrom(path: Any, source: Supplier) { + val file = project.file(path).toPath() + doFirst { + Files.createDirectories(file.parent) + if (!Files.exists(file)) Files.copy(source.get().toPath(), file) + } + } + + @TaskAction + override fun exec() { + Files.createDirectories(workingDir.toPath()) + fsOperations.delete { delete(workingDir.resolve("screenshots")) } + + if (useFramebuffer) { + clientRunner.get().wrapClient(this) { super.exec() } + } else { + super.exec() + } + } + + @get:Inject + protected abstract val fsOperations: FileSystemOperations +} + +/** + * A service for [JavaExec] tasks which start Minecraft. + * + * Tasks may run `usesService(MinecraftRunnerService.get(gradle))` to ensure that only one Minecraft-related task runs + * at once. + */ +abstract class MinecraftRunnerService : BuildService { + private val hasXvfb = lazy { + System.getProperty("os.name", "").equals("linux", ignoreCase = true) && ProcessHelpers.onPath("xvfb-run") + } + + internal fun wrapClient(exec: JavaExec, run: () -> Unit) = when { + hasXvfb.value -> runXvfb(exec, run) + else -> run() + } + + /** + * Run a program under Xvfb, preventing it spawning a window. + */ + private fun runXvfb(exec: JavaExec, run: () -> Unit) { + fun ProcessBuilder.startVerbose(): Process { + exec.logger.info("Running ${this.command()}") + return start() + } + + CloseScope().use { scope -> + val dir = Files.createTempDirectory("cctweaked").toAbsolutePath() + scope.add { fsOperations.delete { delete(dir) } } + + val authFile = Files.createTempFile(dir, "Xauthority", "").toAbsolutePath() + + val cookie = StringBuilder().also { + for (i in 0..31) it.append("0123456789abcdef"[Random.nextInt(16)]) + }.toString() + + val xvfb = + ProcessBuilder("Xvfb", "-displayfd", "1", "-screen", "0", "640x480x24", "-nolisten", "tcp").also { + it.inheritIO() + it.environment()["XAUTHORITY"] = authFile.toString() + it.redirectOutput(ProcessBuilder.Redirect.PIPE) + }.startVerbose() + scope.add { xvfb.destroyForcibly().waitFor() } + + val server = xvfb.inputReader().use { it.readLine().trim() } + exec.logger.info("Running at :$server (XAUTHORITY=$authFile.toA") + + ProcessBuilder("xauth", "add", ":$server", ".", cookie).also { + it.inheritIO() + it.environment()["XAUTHORITY"] = authFile.toString() + }.startVerbose().waitForOrThrow("Failed to setup XAuthority file") + + scope.add { + ProcessBuilder("xauth", "remove", ":$server").also { + it.inheritIO() + it.environment()["XAUTHORITY"] = authFile.toString() + }.startVerbose().waitFor() + } + + // Wait a few seconds for Xvfb to start. Ugly, but identical to xvfb-run. + if (xvfb.waitFor(3, TimeUnit.SECONDS)) { + throw GradleException("Xvfb unexpectedly exited (with status code ${xvfb.exitValue()})") + } + + exec.environment("XAUTHORITY", authFile.toString()) + exec.environment("DISPLAY", ":$server") + + run() + } + } + + @get:Inject + protected abstract val fsOperations: FileSystemOperations + + companion object { + fun get(gradle: Gradle): Provider = + gradle.sharedServices.registerIfAbsent("cc.tweaked.gradle.ClientJavaExec", MinecraftRunnerService::class.java) { + maxParallelUsages.set(1) + } + } +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt new file mode 100644 index 000000000..2fdb2a650 --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.gradle + +import org.codehaus.groovy.runtime.ProcessGroovyMethods +import org.gradle.api.GradleException +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets + +internal object ProcessHelpers { + fun startProcess(vararg command: String): Process { + // Something randomly passes in "GIT_DIR=" as an environment variable which clobbers everything else. Don't + // inherit the environment array! + return ProcessBuilder() + .command(*command) + .redirectError(ProcessBuilder.Redirect.INHERIT) + .also { it.environment().clear() } + .start() + } + + fun captureOut(vararg command: String): String { + val process = startProcess(*command) + process.outputStream.close() + + val result = ProcessGroovyMethods.getText(process) + process.waitForOrThrow("Failed to run command") + return result + } + + fun captureLines(vararg command: String): List { + val process = startProcess(*command) + process.outputStream.close() + + val out = BufferedReader(InputStreamReader(process.inputStream, StandardCharsets.UTF_8)).use { reader -> + reader.lines().filter { it.isNotEmpty() }.toList() + } + ProcessGroovyMethods.closeStreams(process) + process.waitForOrThrow("Failed to run command") + return out + } + + fun onPath(name: String): Boolean { + val path = System.getenv("PATH") ?: return false + return path.splitToSequence(File.pathSeparator).any { File(it, name).exists() } + } + + /** + * Search for an executable on the `PATH` if required. + * + * [Process]/[ProcessBuilder] does not handle all executable file extensions on Windows (such as `.com). When on + * Windows, this function searches `PATH` and `PATHEXT` for an executable matching [name]. + */ + fun getExecutable(name: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) return name + + val path = (System.getenv("PATH") ?: return name).split(File.pathSeparator) + val pathExt = (System.getenv("PATHEXT") ?: return name).split(File.pathSeparator) + + for (pathEntry in path) { + for (ext in pathExt) { + val resolved = File(pathEntry, name + ext) + if (resolved.exists()) return resolved.getAbsolutePath() + } + } + + return name + } +} + +internal fun Process.waitForOrThrow(message: String) { + val ret = waitFor() + if (ret != 0) throw GradleException("$message (exited with $ret)") +} diff --git a/buildSrc/src/main/kotlin/net/minecraftforge/gradle/common/util/runs/RunConfigSetup.kt b/buildSrc/src/main/kotlin/net/minecraftforge/gradle/common/util/runs/RunConfigSetup.kt new file mode 100644 index 000000000..3494c5358 --- /dev/null +++ b/buildSrc/src/main/kotlin/net/minecraftforge/gradle/common/util/runs/RunConfigSetup.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package net.minecraftforge.gradle.common.util.runs + +import net.minecraftforge.gradle.common.util.RunConfig +import org.gradle.api.Project +import org.gradle.process.CommandLineArgumentProvider +import org.gradle.process.JavaExecSpec +import java.io.File + +/** + * Set up a [JavaExecSpec] to execute a [RunConfig]. + * + * [MinecraftRunTask] sets up all its properties when the task is executed, rather than when configured. As such, it's + * not possible to use [cc.tweaked.gradle.copyToFull] like we do for Fabric. Instead, we set up the task manually. + * + * Unfortunately most of the functionality we need is package-private, and so we have to put our code into the package. + */ +internal fun setRunConfigInternal(project: Project, spec: JavaExecSpec, config: RunConfig) { + spec.workingDir = File(config.workingDirectory) + + spec.mainClass.set(config.main) + for (source in config.allSources) spec.classpath(source.runtimeClasspath) + + val originalTask = project.tasks.named(config.taskName, MinecraftRunTask::class.java) + + // Add argument and JVM argument via providers, to be as lazy as possible with fetching artifacts. + val lazyTokens = RunConfigGenerator.configureTokensLazy( + project, config, RunConfigGenerator.mapModClassesToGradle(project, config), + originalTask.get().minecraftArtifacts, + originalTask.get().runtimeClasspathArtifacts, + ) + spec.argumentProviders.add( + CommandLineArgumentProvider { + RunConfigGenerator.getArgsStream(config, lazyTokens, false).toList() + }, + ) + spec.jvmArgumentProviders.add( + CommandLineArgumentProvider { + (if (config.isClient) config.jvmArgs + originalTask.get().additionalClientArgs.get() else config.jvmArgs).map { config.replace(lazyTokens, it) } + + config.properties.map { (k, v) -> "-D${k}=${config.replace(lazyTokens, v)}" } + }, + ) + + for ((key, value) in config.environment) spec.environment(key, config.replace(lazyTokens, value)) +} diff --git a/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt b/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt index e986197ee..5a2384351 100644 --- a/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt +++ b/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt @@ -81,7 +81,7 @@ object ClientTestHooks { if (minecraft.levelSource.levelExists(LEVEL_NAME)) { LOG.info("World already exists, opening.") - minecraft.createWorldOpenFlows().loadLevel(minecraft.screen, LEVEL_NAME) + minecraft.createWorldOpenFlows().loadLevel(minecraft.screen!!, LEVEL_NAME) } else { LOG.info("World does not exist, creating it.") val rules = GameRules() From 6227fd68b4c44e83a6b9632220205e60fece16b4 Mon Sep 17 00:00:00 2001 From: Dogboy21 Date: Sun, 2 Jun 2024 01:41:44 +0200 Subject: [PATCH 3/3] Add docs on client tests --- docs/CREATING_TESTS.MD | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/CREATING_TESTS.MD b/docs/CREATING_TESTS.MD index 5d771e476..15a348581 100644 --- a/docs/CREATING_TESTS.MD +++ b/docs/CREATING_TESTS.MD @@ -9,7 +9,7 @@ To write a test, you need to have a test world. But this needs to be done in the To run the testing environment, run the gradle task `runTestClient`. This will start a new instance of Minecraft with the testing environment. That includes the ability to run tests, import and export tests using the `/cctest` and the `/test` command. -After the tests are created, you can use the gradle task `runTestServer` to start the game test server which then runs all the test. +After the tests are created, you can use the gradle task `runGameTestServer` to start the game test server which then runs all the test. To test a single test, you can also use `/test run ` in the testing environment. ### Building your test structure @@ -120,4 +120,14 @@ Here is an example with our note block test } ``` +### Client Tests + +Similar to the common game tests described above, you can also write tests that will be executed on the client. +To define a client test, use the annotation `@ClientGameTest` instead of `@GameTest`. The rest of the process is the same. +In the test function, you can then use `thenOnClient { ... }` to run code on the client. + +To run the client tests automatically, use the gradle task `runGameTestClient`. + +--- + For more examples, you can also check how the tests from CC work [here](https://github.com/cc-tweaked/CC-Tweaked/tree/mc-1.19.2/src/testMod/kotlin/dan200/computercraft/gametest). \ No newline at end of file