1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2024-12-14 20:20:30 +00:00

Add a system for client-side tests (#1219)

- Add a new ClientJavaExec Gradle task, which is used for client-side
   tests. This:

   - Copies the exec spec from another JavaExec task.
   - Sets some additional system properties to configure on gametest framework.
   - Runs Java inside an X framebuffer (when available), meaning we
     don't need to spin up a new window.

   We also configure this task so that only one instance can run at
   once, meaning we don't spawn multiple MC windows at once!

 - Port our 1.16 client test framework to 1.19. This is mostly the same
   as before, but screenshots no longer do a golden test: they /just/
   write to a folder. Screenshots are compared manually afterwards.

   This is still pretty brittle, and there's a lot of sleeps scattered
   around in the code. It's not clear how well this will play on CI.

 - Roll our own game test loader, rather than relying on the mod loader
   to do it for us. This ensures that loading is consistent between
   platforms (we already had to do some hacks for Forge) and makes it
   easier to provide custom logic for loading client-only tests.

 - Run several client tests (namely those involving monitor rendering)
   against Sodium and Iris too. There's some nastiness here to set up
   new Loom run configurations and automatically configure Iris to use
   Complementary Shaders, but it's not too bad. These tests /don't/ run
   on CI, so it doesn't need to be as reliable.
This commit is contained in:
Jonathan Coates 2022-11-18 23:57:25 +00:00 committed by GitHub
parent b58b9b7df3
commit 8f92417a2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1634 additions and 329 deletions

View File

@ -33,6 +33,9 @@ jobs:
./gradlew downloadAssets || ./gradlew downloadAssets
./gradlew build
- name: Run client tests
run: ./gradlew runGametestClient # Not checkClient, as no point running rendering tests.
- name: Upload Jar
uses: actions/upload-artifact@v2
with:

View File

@ -4,22 +4,21 @@ import net.ltgt.gradle.errorprone.CheckSeverity
import net.ltgt.gradle.errorprone.errorprone
import org.gradle.api.NamedDomainObjectProvider
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.attributes.TestSuiteType
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.provider.Provider
import org.gradle.api.provider.SetProperty
import org.gradle.api.reporting.ReportingExtension
import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.SourceSet
import org.gradle.api.tasks.SourceSetContainer
import org.gradle.api.tasks.bundling.Jar
import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.api.tasks.javadoc.Javadoc
import org.gradle.configurationcache.extensions.capitalized
import org.gradle.kotlin.dsl.get
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.language.jvm.tasks.ProcessResources
import org.gradle.process.JavaForkOptions
import org.gradle.testing.jacoco.plugins.JacocoCoverageReport
import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
@ -27,8 +26,11 @@ import org.gradle.testing.jacoco.tasks.JacocoReport
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.BufferedWriter
import java.io.File
import java.io.IOException
import java.io.OutputStreamWriter
import java.net.URI
import java.net.URL
import java.util.regex.Pattern
abstract class CCTweakedExtension(
@ -79,8 +81,7 @@ abstract class CCTweakedExtension(
/**
* References to other sources
*/
val sourceDirectories: SetProperty<SourceSetReference> =
project.objects.setProperty(SourceSetReference::class.java)
val sourceDirectories: SetProperty<SourceSetReference> = project.objects.setProperty(SourceSetReference::class.java)
/** All source sets referenced by this project. */
val sourceSets = sourceDirectories.map { x -> x.map { it.sourceSet } }
@ -181,7 +182,7 @@ abstract class CCTweakedExtension(
}
}
fun jacoco(task: NamedDomainObjectProvider<JavaExec>) {
fun <T> jacoco(task: NamedDomainObjectProvider<T>) where T : Task, T : JavaForkOptions {
val classDump = project.buildDir.resolve("jacocoClassDump/${task.name}")
val reportTaskName = "jacoco${task.name.capitalized()}Report"
@ -210,8 +211,7 @@ abstract class CCTweakedExtension(
classDirectories.from(classDump)
// Don't want to use sourceSets(...) here as we have a custom class directory.
val sourceSets = project.extensions.getByType(SourceSetContainer::class.java)
sourceDirectories.from(sourceSets["main"].allSource.sourceDirectories)
for (ref in sourceSets.get()) sourceDirectories.from(ref.allSource.sourceDirectories)
}
project.extensions.configure(ReportingExtension::class.java) {
@ -221,6 +221,41 @@ abstract class CCTweakedExtension(
}
}
/**
* Download a file by creating a dummy Ivy repository.
*
* This should only be used for one-off downloads. Using a more conventional Ivy or Maven repository is preferred
* where possible.
*/
fun downloadFile(label: String, url: String): File {
val url = URL(url)
val path = File(url.path)
project.repositories.ivy {
name = label
setUrl(URI(url.protocol, url.userInfo, url.host, url.port, path.parent, null, null))
patternLayout {
artifact("[artifact].[ext]")
}
metadataSources {
artifact()
}
content {
includeModule("cc.tweaked.internal", path.nameWithoutExtension)
}
}
return project.configurations.detachedConfiguration(
project.dependencies.create(
mapOf(
"group" to "cc.tweaked.internal",
"name" to path.nameWithoutExtension,
"ext" to path.extension,
),
),
).resolve().single()
}
companion object {
private val EMAIL = Pattern.compile("^([^<]+) <.+>$")
private val IGNORED_USERS = setOf(
@ -238,7 +273,7 @@ abstract class CCTweakedExtension(
}
}
internal val isIdeSync: Boolean
private val isIdeSync: Boolean
get() = java.lang.Boolean.parseBoolean(System.getProperty("idea.sync.active", "false"))
}
}

View File

@ -6,7 +6,6 @@ import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import org.gradle.language.base.plugins.LifecycleBasePlugin
import java.nio.charset.StandardCharsets
/**
* Checks the `changelog.md` and `whatsnew.md` files are well-formed.

View File

@ -5,7 +5,6 @@ import com.diffplug.spotless.FormatterStep
import com.diffplug.spotless.generic.LicenseHeaderStep
import java.io.File
import java.io.Serializable
import java.nio.charset.StandardCharsets
/**
* Similar to [LicenseHeaderStep], but supports multiple licenses.

View File

@ -1,11 +1,10 @@
package cc.tweaked.gradle
import org.gradle.api.artifacts.ResolvedDependency
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.api.publish.maven.MavenPublication
import org.gradle.api.specs.Spec
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.
@ -26,10 +25,28 @@ fun DependencyHandler.annotationProcessorEverywhere(dep: Any) {
*/
fun JavaExec.copyToFull(spec: JavaExec) {
copyTo(spec)
spec.classpath = classpath
spec.mainClass.set(mainClass)
spec.javaLauncher.set(javaLauncher)
// 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
}
/**
@ -42,3 +59,59 @@ fun JavaExec.copyToFull(spec: JavaExec) {
* ```
*/
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<AutoCloseable>()
/**
* 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 <R> use(block: (CloseScope) -> R): R {
var exception: Throwable? = null
try {
return block(this)
} catch (e: Throwable) {
exception = e
throw e
} finally {
close(exception)
}
}
}

View File

@ -6,7 +6,6 @@ import org.w3c.dom.Attr
import org.w3c.dom.Document
import org.w3c.dom.Node
import org.xml.sax.InputSource
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import javax.xml.parsers.DocumentBuilderFactory
@ -36,7 +35,7 @@ internal class IdeaRunConfigurations(project: Project) {
val ideaMisc = ideaDir.resolve("misc.xml")
try {
val doc = Files.newBufferedReader(ideaMisc.toPath(), StandardCharsets.UTF_8).use {
val doc = Files.newBufferedReader(ideaMisc.toPath()).use {
documentBuilder.parse(InputSource(it))
}
val node =

View File

@ -0,0 +1,202 @@
package cc.tweaked.gradle
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> = MinecraftRunnerService.get(project.gradle)
init {
group = LifecycleBasePlugin.VERIFICATION_GROUP
usesService(clientRunner)
}
/**
* When [false], tests will not be run automatically, allowing the user to debug rendering.
*/
@get:Input
val clientDebug get() = project.hasProperty("clientDebug")
/**
* When [false], tests will not run under a framebuffer.
*/
@get:Input
val useFramebuffer get() = !clientDebug && !project.hasProperty("clientNoFramebuffer")
/**
* The folder screenshots are written to.
*/
@get:OutputDirectory
val screenshots = project.layout.buildDirectory.dir("testScreenshots")
/**
* The path test results are written to.
*/
@get:OutputFile
val testResults = project.layout.buildDirectory.file("test-results/$name.xml")
/**
* 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)
if (!clientDebug) systemProperty("cctest.client", "")
systemProperty("cctest.gametest-report", testResults.get().asFile.absoluteFile)
workingDir(project.buildDir.resolve("gametest").resolve(name))
}
/**
* Only run tests with the given tags.
*/
fun tags(vararg tags: String) {
systemProperty("cctest.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<String>) {
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<File>) {
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()
}
fsOperations.copy {
from(workingDir.resolve("screenshots"))
into(screenshots)
}
}
@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<BuildServiceParameters.None> {
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<MinecraftRunnerService> =
gradle.sharedServices.registerIfAbsent("cc.tweaked.gradle.ClientJavaExec", MinecraftRunnerService::class.java) {
maxParallelUsages.set(1)
}
}
}

View File

@ -1,7 +1,9 @@
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.IOException
import java.io.InputStreamReader
@ -31,4 +33,14 @@ internal object ProcessHelpers {
if (process.waitFor() != 0) throw IOException("Command exited with a non-0 status")
return out
}
fun onPath(name: String): Boolean {
val path = System.getenv("PATH") ?: return false
return path.splitToSequence(File.pathSeparator).any { File(it, name).exists() }
}
}
internal fun Process.waitForOrThrow(message: String) {
val ret = waitFor()
if (ret != 0) throw GradleException("$message (exited with $ret)")
}

View File

@ -28,10 +28,12 @@ slf4j = "1.7.36"
# Minecraft mods
forgeConfig = "4.2.6"
iris = "1.19.x-v1.2.6"
iris = "1.19.x-v1.4.0"
jei = "11.3.0.262"
oculus = "1.2.5"
rei = "9.1.550"
rubidium = "0.6.1"
sodium = "mc1.19.2-0.4.4"
# Testing
byteBuddy = "1.12.18"
@ -91,6 +93,8 @@ oculus = { module = "maven.modrinth:oculus", version.ref = "oculus" }
rei-api = { module = "me.shedaniel:RoughlyEnoughItems-api", version.ref = "rei" }
rei-builtin = { module = "me.shedaniel:RoughlyEnoughItems-default-plugin", version.ref = "rei" }
rei-fabric = { module = "me.shedaniel:RoughlyEnoughItems-fabric", version.ref = "rei" }
rubidium = { module = "maven.modrinth:rubidium", version.ref = "rubidium" }
sodium = { module = "maven.modrinth:sodium", version.ref = "sodium" }
# Testing
byteBuddyAgent = { module ="net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" }

View File

@ -10,6 +10,7 @@ import net.minecraft.core.Registry;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.enchantment.Enchantment;
@ -31,6 +32,7 @@ public final class Registries {
public static final RegistryWrapper<ArgumentTypeInfo<?, ?>> COMMAND_ARGUMENT_TYPES = PlatformHelper.get().wrap(Registry.COMMAND_ARGUMENT_TYPE_REGISTRY);
public static final RegistryWrapper<SoundEvent> SOUND_EVENTS = PlatformHelper.get().wrap(Registry.SOUND_EVENT_REGISTRY);
public static final RegistryWrapper<RecipeSerializer<?>> RECIPE_SERIALIZERS = PlatformHelper.get().wrap(Registry.RECIPE_SERIALIZER_REGISTRY);
public static final RegistryWrapper<MenuType<?>> MENU = PlatformHelper.get().wrap(Registry.MENU_REGISTRY);
public interface RegistryWrapper<T> extends Iterable<T> {
int getId(T object);

View File

@ -1,23 +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.api;
import net.minecraft.gametest.framework.GameTest;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks classes containing {@linkplain GameTest game tests}.
* <p>
* This is used by Forge to automatically load and test classes.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface GameTestHolder {
}

View File

@ -105,7 +105,7 @@ class CCTestCommand {
}
private static Path getSourceComputerPath() {
return TestHooks.sourceDir.resolve("computer");
return TestHooks.getSourceDir().resolve("computer");
}
private static int error(CommandSourceStack source, String message) {

View File

@ -12,6 +12,8 @@ import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.gametest.api.ComputerState;
import dan200.computercraft.gametest.api.TestExtensionsKt;
import net.minecraft.gametest.framework.GameTestSequence;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.util.Optional;
@ -24,6 +26,8 @@ import java.util.Optional;
* @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 @Nullable String label;
@ -36,10 +40,10 @@ public class TestAPI extends ComputerState implements ILuaAPI {
if (label == null) label = system.getLabel();
if (label == null) {
label = "#" + system.getID();
TestHooks.LOG.warn("Computer {} has no label", label);
LOG.warn("Computer {} has no label", label);
}
TestHooks.LOG.info("Computer '{}' has turned on.", label);
LOG.info("Computer '{}' has turned on.", label);
markers.clear();
error = null;
lookup.put(label, this);
@ -47,7 +51,7 @@ public class TestAPI extends ComputerState implements ILuaAPI {
@Override
public void shutdown() {
TestHooks.LOG.info("Computer '{}' has shut down.", label);
LOG.info("Computer '{}' has shut down.", label);
if (lookup.get(label) == this) lookup.remove(label);
}
@ -58,7 +62,7 @@ public class TestAPI extends ComputerState implements ILuaAPI {
@LuaFunction
public final void fail(String message) throws LuaException {
TestHooks.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;
@ -77,6 +81,6 @@ public class TestAPI extends ComputerState implements ILuaAPI {
@LuaFunction
public final void log(String message) {
TestHooks.LOG.info("[Computer '{}'] {}", label, message);
LOG.info("[Computer '{}'] {}", label, message);
}
}

View File

@ -1,55 +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.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 LOG = LoggerFactory.getLogger(TestHooks.class);
public static final Path sourceDir = Paths.get(System.getProperty("cctest.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) {
var rules = server.getGameRules();
rules.getRule(GameRules.RULE_DAYLIGHT).set(false, server);
var world = server.getLevel(Level.OVERWORLD);
if (world != null) world.setDayTime(Times.NOON);
LOG.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();
LOG.warn("Unexpected computer {}", label);
}
LOG.info("Importing files");
CCTestCommand.importFiles(server);
}
}

View File

@ -14,4 +14,7 @@ import org.spongepowered.asm.mixin.gen.Accessor;
public interface GameTestSequenceAccessor {
@Accessor
GameTestInfo getParent();
@Accessor
long getLastTick();
}

View File

@ -0,0 +1,23 @@
/*
* 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;
import net.minecraft.SharedConstants;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Overwrite;
@Mixin(SharedConstants.class)
class SharedConstantsMixin {
/**
* Disable DFU initialisation.
*
* @author SquidDev
* @reason This doesn't have any impact on gameplay, and slightly speeds up tests.
*/
@Overwrite
public static void enableDataFixerOptimizations() {
}
}

View File

@ -5,18 +5,25 @@
*/
package dan200.computercraft.gametest
import dan200.computercraft.api.lua.ObjectArguments
import dan200.computercraft.client.gui.AbstractComputerScreen
import dan200.computercraft.core.apis.RedstoneAPI
import dan200.computercraft.core.apis.TermAPI
import dan200.computercraft.core.computer.ComputerSide
import dan200.computercraft.gametest.api.*
import dan200.computercraft.shared.ModRegistry
import dan200.computercraft.test.core.computer.getApi
import net.minecraft.core.BlockPos
import net.minecraft.gametest.framework.GameTest
import net.minecraft.gametest.framework.GameTestHelper
import net.minecraft.world.InteractionHand
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.LeverBlock
import net.minecraft.world.level.block.RedstoneLampBlock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.lwjgl.glfw.GLFW
@GameTestHolder
class Computer_Test {
/**
* Ensures redstone signals do not travel through computers.
@ -50,6 +57,9 @@ class Computer_Test {
thenExecute { context.assertBlockHas(lamp, RedstoneLampBlock.LIT, false, "Lamp should still not be lit") }
}
/**
* Check computers propagate redstone to surrounding blocks.
*/
@GameTest
fun Set_and_destroy(context: GameTestHelper) = context.sequence {
val lamp = BlockPos(2, 2, 3)
@ -62,6 +72,9 @@ class Computer_Test {
thenExecute { context.assertBlockHas(lamp, RedstoneLampBlock.LIT, false, "Lamp should not be lit") }
}
/**
* Check computers and turtles expose peripherals.
*/
@GameTest
fun Computer_peripheral(context: GameTestHelper) = context.sequence {
thenExecute {
@ -69,4 +82,62 @@ class Computer_Test {
context.assertPeripheral(BlockPos(1, 2, 2), type = "turtle")
}
}
/**
* Check the client can open the computer UI and interact with it.
*/
@ClientGameTest
fun Open_on_client(context: GameTestHelper) = context.sequence {
// Write "Hello, world!" and then print each event to the terminal.
thenOnComputer { getApi<TermAPI>().write(ObjectArguments("Hello, world!")) }
thenStartComputer {
val term = getApi<TermAPI>().terminal
while (true) {
val event = pullEvent()
if (term.cursorY >= term.height) {
term.scroll(1)
term.setCursorPos(0, term.height)
} else {
term.setCursorPos(0, term.cursorY + 1)
}
term.write(event.contentToString())
}
}
// Teleport the player to the computer and then open it.
thenExecute {
context.positionAt(BlockPos(2, 2, 1))
val computer = context.getBlockEntity(BlockPos(2, 2, 2), ModRegistry.BlockEntities.COMPUTER_ADVANCED.get())
computer.use(context.level.randomPlayer!!, InteractionHand.MAIN_HAND)
}
// Assert the terminal is synced to the client.
thenIdle(2)
thenOnClient {
val menu = getOpenMenu(ModRegistry.Menus.COMPUTER.get())
val term = menu.terminal
assertEquals("Hello, world!", term.getLine(0).toString().trim(), "Terminal contents is synced")
assertTrue(menu.isOn, "Computer is on")
}
// Press a key on the client
thenOnClient {
val screen = minecraft.screen as AbstractComputerScreen<*>
screen.keyPressed(GLFW.GLFW_KEY_A, 0, 0)
screen.keyReleased(GLFW.GLFW_KEY_A, 0, 0)
}
// And assert it is handled and sent back to the client
thenIdle(2)
thenOnClient {
val term = getOpenMenu(ModRegistry.Menus.COMPUTER.get()).terminal
assertEquals(
"[key, ${GLFW.GLFW_KEY_A}, false]",
term.getLine(1).toString().trim(),
"Terminal contents is synced",
)
assertEquals(
"[key_up, ${GLFW.GLFW_KEY_A}]",
term.getLine(2).toString().trim(),
"Terminal contents is synced",
)
}
}
}

View File

@ -5,14 +5,12 @@
*/
package dan200.computercraft.gametest
import dan200.computercraft.gametest.api.GameTestHolder
import dan200.computercraft.gametest.api.Timeouts
import dan200.computercraft.gametest.api.sequence
import dan200.computercraft.gametest.api.thenComputerOk
import net.minecraft.gametest.framework.GameTest
import net.minecraft.gametest.framework.GameTestHelper
@GameTestHolder
class CraftOs_Test {
/**
* Sends a rednet message to another a computer and back again.

View File

@ -21,7 +21,6 @@ import net.minecraft.world.item.Items
import net.minecraft.world.level.block.RedStoneWireBlock
import org.junit.jupiter.api.Assertions.assertEquals
@GameTestHolder
class Disk_Drive_Test {
/**
* Ensure audio disks exist and we can play them.

View File

@ -5,7 +5,6 @@
*/
package dan200.computercraft.gametest
import dan200.computercraft.gametest.api.GameTestHolder
import dan200.computercraft.gametest.api.Structures
import dan200.computercraft.gametest.api.sequence
import dan200.computercraft.shared.ModRegistry
@ -16,7 +15,6 @@ import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.entity.ChestBlockEntity
import net.minecraft.world.level.storage.loot.BuiltInLootTables
@GameTestHolder
class Loot_Test {
/**
* Test that the loot tables will spawn in treasure disks.

View File

@ -20,7 +20,6 @@ import net.minecraft.gametest.framework.GameTestHelper
import org.junit.jupiter.api.Assertions.assertEquals
import kotlin.time.Duration.Companion.milliseconds
@GameTestHolder
class Modem_Test {
@GameTest
fun Have_peripherals(helper: GameTestHelper) = helper.sequence {

View File

@ -7,17 +7,20 @@ package dan200.computercraft.gametest
import dan200.computercraft.gametest.api.*
import dan200.computercraft.shared.ModRegistry
import dan200.computercraft.shared.config.Config
import dan200.computercraft.shared.peripheral.monitor.MonitorBlock
import dan200.computercraft.shared.peripheral.monitor.MonitorEdgeState
import dan200.computercraft.shared.peripheral.monitor.MonitorRenderer
import net.minecraft.commands.arguments.blocks.BlockInput
import net.minecraft.core.BlockPos
import net.minecraft.gametest.framework.GameTest
import net.minecraft.gametest.framework.GameTestGenerator
import net.minecraft.gametest.framework.GameTestHelper
import net.minecraft.gametest.framework.TestFunction
import net.minecraft.nbt.CompoundTag
import net.minecraft.world.level.block.Blocks
import java.util.*
@GameTestHolder
class Monitor_Test {
@GameTest
fun Ensures_valid_on_place(context: GameTestHelper) = context.sequence {
@ -58,4 +61,64 @@ class Monitor_Test {
helper.assertBlockHas(BlockPos(3, 2, 2), MonitorBlock.STATE, MonitorEdgeState.NONE)
}
}
/**
* Test
*/
@GameTestGenerator
fun Render_monitor_tests(): List<TestFunction> {
val tests = mutableListOf<TestFunction>()
fun addTest(label: String, renderer: MonitorRenderer, time: Long = Times.NOON, tag: String = TestTags.CLIENT) {
if (!TestTags.isEnabled(tag)) return
val className = Monitor_Test::class.java.simpleName.lowercase()
val testName = "$className.render_monitor"
tests.add(
TestFunction(
"$testName.$label",
"$testName.$label",
testName,
Timeouts.DEFAULT,
0,
true,
) { renderMonitor(it, renderer, time) },
)
}
addTest("tbo_noon", MonitorRenderer.TBO, Times.NOON)
addTest("tbo_midnight", MonitorRenderer.TBO, Times.MIDNIGHT)
addTest("vbo_noon", MonitorRenderer.VBO, Times.NOON)
addTest("vbo_midnight", MonitorRenderer.VBO, Times.MIDNIGHT)
addTest("sodium_tbo", MonitorRenderer.TBO, tag = "sodium")
addTest("sodium_vbo", MonitorRenderer.VBO, tag = "sodium")
addTest("iris_noon", MonitorRenderer.BEST, Times.NOON, tag = "iris")
addTest("iris_midnight", MonitorRenderer.BEST, Times.MIDNIGHT, tag = "iris")
return tests
}
private fun renderMonitor(helper: GameTestHelper, renderer: MonitorRenderer, time: Long) = helper.sequence {
thenExecute {
Config.monitorRenderer = renderer
helper.level.dayTime = time
helper.positionAtArmorStand()
// Get the monitor and peripheral. This forces us to create a server monitor at this location.
val monitor = helper.getBlockEntity(BlockPos(2, 2, 3), ModRegistry.BlockEntities.MONITOR_ADVANCED.get())
monitor.peripheral()
val terminal = monitor.cachedServerMonitor!!.terminal!!
terminal.write("Hello, world!")
terminal.setCursorPos(1, 2)
terminal.textColour = 2
terminal.backgroundColour = 3
terminal.write("Some coloured text")
}
thenScreenshot()
}
}

View File

@ -1,6 +1,9 @@
package dan200.computercraft.gametest
import dan200.computercraft.gametest.api.*
import dan200.computercraft.gametest.api.assertBlockHas
import dan200.computercraft.gametest.api.assertExactlyItems
import dan200.computercraft.gametest.api.getBlockEntity
import dan200.computercraft.gametest.api.sequence
import dan200.computercraft.shared.ModRegistry
import dan200.computercraft.shared.peripheral.printer.PrinterBlock
import net.minecraft.core.BlockPos
@ -11,7 +14,6 @@ import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.block.RedStoneWireBlock
@GameTestHolder
class Printer_Test {
/**
* Check comparators can read the contents of the disk drive

View File

@ -5,7 +5,6 @@
*/
package dan200.computercraft.gametest
import dan200.computercraft.gametest.api.GameTestHolder
import dan200.computercraft.gametest.api.Structures
import dan200.computercraft.gametest.api.sequence
import dan200.computercraft.shared.ModRegistry
@ -24,7 +23,6 @@ import net.minecraft.world.item.crafting.RecipeType
import org.junit.jupiter.api.Assertions.assertEquals
import java.util.*
@GameTestHolder
class Recipe_Test {
/**
* Test that crafting results contain NBT data.

View File

@ -38,7 +38,6 @@ import org.junit.jupiter.api.Assertions.assertNotEquals
import java.util.*
import kotlin.time.Duration.Companion.milliseconds
@GameTestHolder
class Turtle_Test {
@GameTest
fun Unequip_refreshes_peripheral(helper: GameTestHelper) = helper.sequence {
@ -421,7 +420,7 @@ class Turtle_Test {
turtle.back().await().assertArrayEquals(true, message = "Moved turtle forward")
TestHooks.LOG.info("[{}] Finished turtle at {}", testInfo, testInfo.`computercraft$getTick`())
}
thenIdle(2) // Should happen immediately, but computers might be slow.
thenIdle(4) // Should happen immediately, but computers might be slow.
thenExecute {
assertEquals(
listOf(

View File

@ -0,0 +1,28 @@
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,
)

View File

@ -0,0 +1,132 @@
package dan200.computercraft.gametest.api
import dan200.computercraft.mixin.gametest.GameTestSequenceAccessor
import dan200.computercraft.shared.platform.Registries
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 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 = level != null && player != null &&
levelRenderer.isChunkCompiled(player!!.blockPosition()) && levelRenderer.countRenderedChunks() > 10 &&
levelRenderer.hasRenderedAllChunks()
/**
* Run a task on the client.
*/
fun GameTestSequence.thenOnClient(task: ClientTestHelper.() -> Unit): GameTestSequence {
var future: CompletableFuture<Void>? = 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): 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 = true }
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) {
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, 0.0f, 0.0f)
}
/**
* 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 <T : AbstractContainerMenu> getOpenMenu(type: MenuType<T>): T {
fun getName(type: MenuType<*>) = Registries.MENU.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
}
}
}

View File

@ -7,6 +7,7 @@ package dan200.computercraft.gametest.api
import dan200.computercraft.gametest.core.ManagedComputers
import dan200.computercraft.mixin.gametest.GameTestHelperAccessor
import dan200.computercraft.mixin.gametest.GameTestInfoAccessor
import dan200.computercraft.mixin.gametest.GameTestSequenceAccessor
import dan200.computercraft.shared.platform.PlatformHelper
import dan200.computercraft.shared.platform.Registries
@ -41,6 +42,8 @@ object Structures {
/** Pre-set in-game times */
object Times {
const val NOON: Long = 6000
const val MIDNIGHT: Long = 18000
}
/**
@ -49,7 +52,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
}
@ -90,11 +95,18 @@ fun GameTestSequence.thenStartComputer(name: String? = null, action: suspend Lua
* Run a task on a computer and wait for it to finish.
*/
fun GameTestSequence.thenOnComputer(name: String? = null, action: suspend LuaTaskContext.() -> Unit): GameTestSequence {
val test = (this as GameTestSequenceAccessor).parent
val self = (this as GameTestSequenceAccessor)
val test = self.parent
val label = test.testName + (if (name == null) "" else ".$name")
var monitor: ManagedComputers.Monitor? = null
thenExecuteFailFast { monitor = ManagedComputers.enqueue(test, label, action) }
thenWaitUntil { if (!monitor!!.isFinished) throw GameTestAssertException("Computer '$label' has not finished yet.") }
thenWaitUntil {
if (!monitor!!.isFinished) {
val runningFor = (test as GameTestInfoAccessor).`computercraft$getTick`() - self.lastTick
throw GameTestAssertException("Computer '$label' has not finished yet (running for $runningFor ticks).")
}
}
thenExecuteFailFast { monitor!!.check() }
return this
}

View File

@ -0,0 +1,16 @@
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<String> = System.getProperty("cctest.tags", COMMON).split(',').toSet()
fun isEnabled(tag: String) = tags.contains(tag)
}

View File

@ -0,0 +1,189 @@
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.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.DataPackConfig
import net.minecraft.world.level.GameRules
import net.minecraft.world.level.GameType
import net.minecraft.world.level.LevelSettings
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("cctest.client") != null
private var loadedWorld: Boolean = false
@JvmStatic
fun onOpenScreen(screen: Screen): Boolean = when {
enabled && !loadedWorld && screen is TitleScreen -> {
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, 0.0f)
minecraft.options.setSoundCategoryVolume(SoundSource.AMBIENT, 0.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 registries = RegistryAccess.builtinCopy().freeze()
minecraft.createWorldOpenFlows().createFreshLevel(
LEVEL_NAME,
LevelSettings("Test Level", GameType.CREATIVE, false, Difficulty.EASY, true, rules, DataPackConfig.DEFAULT),
registries,
registries
.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
},
)
}
}
}
}

View File

@ -0,0 +1,168 @@
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("cctest.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("cctest.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 testClasses = listOf(
Computer_Test::class.java,
CraftOs_Test::class.java,
Disk_Drive_Test::class.java,
Loot_Test::class.java,
Modem_Test::class.java,
Monitor_Test::class.java,
Printer_Test::class.java,
Recipe_Test::class.java,
Turtle_Test::class.java,
)
/**
* Register all of our gametests.
*
* This is super nasty, as it bypasses any loader-specific hooks for registering tests. However, it makes it much
* easier to ensure consistent behaviour between loaders (namely making [GameTest.template] point to a
* structure rather than a per-test-class one), as well as supporting our custom client tests.
*
* @param fallbackRegister A fallback function which registers non-test methods (such as [BeforeBatch]). This
* should be [GameTestRegistry.register] or equivalent.
*/
@JvmStatic
fun loadTests(fallbackRegister: Consumer<Method>) {
for (testClass in testClasses) {
for (method in testClass.declaredMethods) {
registerTest(testClass, method, fallbackRegister)
}
}
}
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
private fun registerTest(testClass: Class<*>, method: Method, fallbackRegister: Consumer<Method>) {
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)
}
}
}

View File

@ -0,0 +1,43 @@
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>) : 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)
}
}

View File

@ -11,6 +11,7 @@
"GameTestInfoAccessor",
"GameTestSequenceAccessor",
"GameTestSequenceMixin",
"SharedConstantsMixin",
"TestCommandAccessor"
]
}

View File

@ -0,0 +1,137 @@
{
DataVersion: 3120,
size: [5, 5, 5],
data: [
{pos: [0, 0, 0], state: "minecraft:polished_andesite"},
{pos: [0, 0, 1], state: "minecraft:polished_andesite"},
{pos: [0, 0, 2], state: "minecraft:polished_andesite"},
{pos: [0, 0, 3], state: "minecraft:polished_andesite"},
{pos: [0, 0, 4], state: "minecraft:polished_andesite"},
{pos: [1, 0, 0], state: "minecraft:polished_andesite"},
{pos: [1, 0, 1], state: "minecraft:polished_andesite"},
{pos: [1, 0, 2], state: "minecraft:polished_andesite"},
{pos: [1, 0, 3], state: "minecraft:polished_andesite"},
{pos: [1, 0, 4], state: "minecraft:polished_andesite"},
{pos: [2, 0, 0], state: "minecraft:polished_andesite"},
{pos: [2, 0, 1], state: "minecraft:polished_andesite"},
{pos: [2, 0, 2], state: "minecraft:polished_andesite"},
{pos: [2, 0, 3], state: "minecraft:polished_andesite"},
{pos: [2, 0, 4], state: "minecraft:polished_andesite"},
{pos: [3, 0, 0], state: "minecraft:polished_andesite"},
{pos: [3, 0, 1], state: "minecraft:polished_andesite"},
{pos: [3, 0, 2], state: "minecraft:polished_andesite"},
{pos: [3, 0, 3], state: "minecraft:polished_andesite"},
{pos: [3, 0, 4], state: "minecraft:polished_andesite"},
{pos: [4, 0, 0], state: "minecraft:polished_andesite"},
{pos: [4, 0, 1], state: "minecraft:polished_andesite"},
{pos: [4, 0, 2], state: "minecraft:polished_andesite"},
{pos: [4, 0, 3], state: "minecraft:polished_andesite"},
{pos: [4, 0, 4], state: "minecraft:polished_andesite"},
{pos: [0, 1, 0], state: "minecraft:air"},
{pos: [0, 1, 1], state: "minecraft:air"},
{pos: [0, 1, 2], state: "minecraft:air"},
{pos: [0, 1, 3], state: "minecraft:air"},
{pos: [0, 1, 4], state: "minecraft:air"},
{pos: [1, 1, 0], state: "minecraft:air"},
{pos: [1, 1, 1], state: "minecraft:air"},
{pos: [1, 1, 2], state: "minecraft:air"},
{pos: [1, 1, 3], state: "minecraft:air"},
{pos: [1, 1, 4], state: "minecraft:air"},
{pos: [2, 1, 0], state: "minecraft:air"},
{pos: [2, 1, 1], state: "minecraft:air"},
{pos: [2, 1, 2], state: "computercraft:computer_advanced{facing:north,state:on}", nbt: {ComputerId: 1, Label: "computer_test.open_on_client", On: 1b, id: "computercraft:computer_advanced"}},
{pos: [2, 1, 3], state: "minecraft:air"},
{pos: [2, 1, 4], state: "minecraft:air"},
{pos: [3, 1, 0], state: "minecraft:air"},
{pos: [3, 1, 1], state: "minecraft:air"},
{pos: [3, 1, 2], state: "minecraft:air"},
{pos: [3, 1, 3], state: "minecraft:air"},
{pos: [3, 1, 4], state: "minecraft:air"},
{pos: [4, 1, 0], state: "minecraft:air"},
{pos: [4, 1, 1], state: "minecraft:air"},
{pos: [4, 1, 2], state: "minecraft:air"},
{pos: [4, 1, 3], state: "minecraft:air"},
{pos: [4, 1, 4], state: "minecraft:air"},
{pos: [0, 2, 0], state: "minecraft:air"},
{pos: [0, 2, 1], state: "minecraft:air"},
{pos: [0, 2, 2], state: "minecraft:air"},
{pos: [0, 2, 3], state: "minecraft:air"},
{pos: [0, 2, 4], state: "minecraft:air"},
{pos: [1, 2, 0], state: "minecraft:air"},
{pos: [1, 2, 1], state: "minecraft:air"},
{pos: [1, 2, 2], state: "minecraft:air"},
{pos: [1, 2, 3], state: "minecraft:air"},
{pos: [1, 2, 4], state: "minecraft:air"},
{pos: [2, 2, 0], state: "minecraft:air"},
{pos: [2, 2, 1], state: "minecraft:air"},
{pos: [2, 2, 2], state: "minecraft:air"},
{pos: [2, 2, 3], state: "minecraft:air"},
{pos: [2, 2, 4], state: "minecraft:air"},
{pos: [3, 2, 0], state: "minecraft:air"},
{pos: [3, 2, 1], state: "minecraft:air"},
{pos: [3, 2, 2], state: "minecraft:air"},
{pos: [3, 2, 3], state: "minecraft:air"},
{pos: [3, 2, 4], state: "minecraft:air"},
{pos: [4, 2, 0], state: "minecraft:air"},
{pos: [4, 2, 1], state: "minecraft:air"},
{pos: [4, 2, 2], state: "minecraft:air"},
{pos: [4, 2, 3], state: "minecraft:air"},
{pos: [4, 2, 4], state: "minecraft:air"},
{pos: [0, 3, 0], state: "minecraft:air"},
{pos: [0, 3, 1], state: "minecraft:air"},
{pos: [0, 3, 2], state: "minecraft:air"},
{pos: [0, 3, 3], state: "minecraft:air"},
{pos: [0, 3, 4], state: "minecraft:air"},
{pos: [1, 3, 0], state: "minecraft:air"},
{pos: [1, 3, 1], state: "minecraft:air"},
{pos: [1, 3, 2], state: "minecraft:air"},
{pos: [1, 3, 3], state: "minecraft:air"},
{pos: [1, 3, 4], state: "minecraft:air"},
{pos: [2, 3, 0], state: "minecraft:air"},
{pos: [2, 3, 1], state: "minecraft:air"},
{pos: [2, 3, 2], state: "minecraft:air"},
{pos: [2, 3, 3], state: "minecraft:air"},
{pos: [2, 3, 4], state: "minecraft:air"},
{pos: [3, 3, 0], state: "minecraft:air"},
{pos: [3, 3, 1], state: "minecraft:air"},
{pos: [3, 3, 2], state: "minecraft:air"},
{pos: [3, 3, 3], state: "minecraft:air"},
{pos: [3, 3, 4], state: "minecraft:air"},
{pos: [4, 3, 0], state: "minecraft:air"},
{pos: [4, 3, 1], state: "minecraft:air"},
{pos: [4, 3, 2], state: "minecraft:air"},
{pos: [4, 3, 3], state: "minecraft:air"},
{pos: [4, 3, 4], state: "minecraft:air"},
{pos: [0, 4, 0], state: "minecraft:air"},
{pos: [0, 4, 1], state: "minecraft:air"},
{pos: [0, 4, 2], state: "minecraft:air"},
{pos: [0, 4, 3], state: "minecraft:air"},
{pos: [0, 4, 4], state: "minecraft:air"},
{pos: [1, 4, 0], state: "minecraft:air"},
{pos: [1, 4, 1], state: "minecraft:air"},
{pos: [1, 4, 2], state: "minecraft:air"},
{pos: [1, 4, 3], state: "minecraft:air"},
{pos: [1, 4, 4], state: "minecraft:air"},
{pos: [2, 4, 0], state: "minecraft:air"},
{pos: [2, 4, 1], state: "minecraft:air"},
{pos: [2, 4, 2], state: "minecraft:air"},
{pos: [2, 4, 3], state: "minecraft:air"},
{pos: [2, 4, 4], state: "minecraft:air"},
{pos: [3, 4, 0], state: "minecraft:air"},
{pos: [3, 4, 1], state: "minecraft:air"},
{pos: [3, 4, 2], state: "minecraft:air"},
{pos: [3, 4, 3], state: "minecraft:air"},
{pos: [3, 4, 4], state: "minecraft:air"},
{pos: [4, 4, 0], state: "minecraft:air"},
{pos: [4, 4, 1], state: "minecraft:air"},
{pos: [4, 4, 2], state: "minecraft:air"},
{pos: [4, 4, 3], state: "minecraft:air"},
{pos: [4, 4, 4], state: "minecraft:air"}
],
entities: [],
palette: [
"minecraft:polished_andesite",
"minecraft:air",
"computercraft:computer_advanced{facing:north,state:on}"
]
}

View File

@ -0,0 +1,144 @@
{
DataVersion: 3120,
size: [5, 5, 5],
data: [
{pos: [0, 0, 0], state: "minecraft:polished_andesite"},
{pos: [0, 0, 1], state: "minecraft:polished_andesite"},
{pos: [0, 0, 2], state: "minecraft:polished_andesite"},
{pos: [0, 0, 3], state: "minecraft:polished_andesite"},
{pos: [0, 0, 4], state: "minecraft:polished_andesite"},
{pos: [1, 0, 0], state: "minecraft:polished_andesite"},
{pos: [1, 0, 1], state: "minecraft:polished_andesite"},
{pos: [1, 0, 2], state: "minecraft:polished_andesite"},
{pos: [1, 0, 3], state: "minecraft:polished_andesite"},
{pos: [1, 0, 4], state: "minecraft:polished_andesite"},
{pos: [2, 0, 0], state: "minecraft:polished_andesite"},
{pos: [2, 0, 1], state: "minecraft:polished_andesite"},
{pos: [2, 0, 2], state: "minecraft:polished_andesite"},
{pos: [2, 0, 3], state: "minecraft:polished_andesite"},
{pos: [2, 0, 4], state: "minecraft:polished_andesite"},
{pos: [3, 0, 0], state: "minecraft:polished_andesite"},
{pos: [3, 0, 1], state: "minecraft:polished_andesite"},
{pos: [3, 0, 2], state: "minecraft:polished_andesite"},
{pos: [3, 0, 3], state: "minecraft:polished_andesite"},
{pos: [3, 0, 4], state: "minecraft:polished_andesite"},
{pos: [4, 0, 0], state: "minecraft:polished_andesite"},
{pos: [4, 0, 1], state: "minecraft:polished_andesite"},
{pos: [4, 0, 2], state: "minecraft:polished_andesite"},
{pos: [4, 0, 3], state: "minecraft:polished_andesite"},
{pos: [4, 0, 4], state: "minecraft:polished_andesite"},
{pos: [0, 1, 0], state: "minecraft:air"},
{pos: [0, 1, 1], state: "minecraft:air"},
{pos: [0, 1, 2], state: "minecraft:air"},
{pos: [0, 1, 3], state: "minecraft:air"},
{pos: [0, 1, 4], state: "minecraft:air"},
{pos: [1, 1, 0], state: "minecraft:air"},
{pos: [1, 1, 1], state: "minecraft:air"},
{pos: [1, 1, 2], state: "minecraft:air"},
{pos: [1, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lu}", nbt: {Height: 2, Width: 3, XIndex: 2, YIndex: 0, id: "computercraft:monitor_advanced"}},
{pos: [1, 1, 4], state: "minecraft:air"},
{pos: [2, 1, 0], state: "minecraft:air"},
{pos: [2, 1, 1], state: "minecraft:air"},
{pos: [2, 1, 2], state: "minecraft:air"},
{pos: [2, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lru}", nbt: {Height: 2, Width: 3, XIndex: 1, YIndex: 0, id: "computercraft:monitor_advanced"}},
{pos: [2, 1, 4], state: "minecraft:air"},
{pos: [3, 1, 0], state: "minecraft:air"},
{pos: [3, 1, 1], state: "minecraft:air"},
{pos: [3, 1, 2], state: "minecraft:air"},
{pos: [3, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:ru}", nbt: {Height: 2, Width: 3, XIndex: 0, YIndex: 0, id: "computercraft:monitor_advanced"}},
{pos: [3, 1, 4], state: "minecraft:air"},
{pos: [4, 1, 0], state: "minecraft:air"},
{pos: [4, 1, 1], state: "minecraft:air"},
{pos: [4, 1, 2], state: "minecraft:air"},
{pos: [4, 1, 3], state: "minecraft:air"},
{pos: [4, 1, 4], state: "minecraft:air"},
{pos: [0, 2, 0], state: "minecraft:air"},
{pos: [0, 2, 1], state: "minecraft:air"},
{pos: [0, 2, 2], state: "minecraft:air"},
{pos: [0, 2, 3], state: "minecraft:air"},
{pos: [0, 2, 4], state: "minecraft:air"},
{pos: [1, 2, 0], state: "minecraft:air"},
{pos: [1, 2, 1], state: "minecraft:air"},
{pos: [1, 2, 2], state: "minecraft:air"},
{pos: [1, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:ld}", nbt: {Height: 2, Width: 3, XIndex: 2, YIndex: 1, id: "computercraft:monitor_advanced"}},
{pos: [1, 2, 4], state: "minecraft:air"},
{pos: [2, 2, 0], state: "minecraft:air"},
{pos: [2, 2, 1], state: "minecraft:air"},
{pos: [2, 2, 2], state: "minecraft:air"},
{pos: [2, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lrd}", nbt: {Height: 2, Width: 3, XIndex: 1, YIndex: 1, id: "computercraft:monitor_advanced"}},
{pos: [2, 2, 4], state: "minecraft:air"},
{pos: [3, 2, 0], state: "minecraft:air"},
{pos: [3, 2, 1], state: "minecraft:air"},
{pos: [3, 2, 2], state: "minecraft:air"},
{pos: [3, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:rd}", nbt: {Height: 2, Width: 3, XIndex: 0, YIndex: 1, id: "computercraft:monitor_advanced"}},
{pos: [3, 2, 4], state: "minecraft:air"},
{pos: [4, 2, 0], state: "minecraft:air"},
{pos: [4, 2, 1], state: "minecraft:air"},
{pos: [4, 2, 2], state: "minecraft:air"},
{pos: [4, 2, 3], state: "minecraft:air"},
{pos: [4, 2, 4], state: "minecraft:air"},
{pos: [0, 3, 0], state: "minecraft:air"},
{pos: [0, 3, 1], state: "minecraft:air"},
{pos: [0, 3, 2], state: "minecraft:air"},
{pos: [0, 3, 3], state: "minecraft:air"},
{pos: [0, 3, 4], state: "minecraft:air"},
{pos: [1, 3, 0], state: "minecraft:air"},
{pos: [1, 3, 1], state: "minecraft:air"},
{pos: [1, 3, 2], state: "minecraft:air"},
{pos: [1, 3, 3], state: "minecraft:air"},
{pos: [1, 3, 4], state: "minecraft:air"},
{pos: [2, 3, 0], state: "minecraft:air"},
{pos: [2, 3, 1], state: "minecraft:air"},
{pos: [2, 3, 2], state: "minecraft:air"},
{pos: [2, 3, 3], state: "minecraft:air"},
{pos: [2, 3, 4], state: "minecraft:air"},
{pos: [3, 3, 0], state: "minecraft:air"},
{pos: [3, 3, 1], state: "minecraft:air"},
{pos: [3, 3, 2], state: "minecraft:air"},
{pos: [3, 3, 3], state: "minecraft:air"},
{pos: [3, 3, 4], state: "minecraft:air"},
{pos: [4, 3, 0], state: "minecraft:air"},
{pos: [4, 3, 1], state: "minecraft:air"},
{pos: [4, 3, 2], state: "minecraft:air"},
{pos: [4, 3, 3], state: "minecraft:air"},
{pos: [4, 3, 4], state: "minecraft:air"},
{pos: [0, 4, 0], state: "minecraft:air"},
{pos: [0, 4, 1], state: "minecraft:air"},
{pos: [0, 4, 2], state: "minecraft:air"},
{pos: [0, 4, 3], state: "minecraft:air"},
{pos: [0, 4, 4], state: "minecraft:air"},
{pos: [1, 4, 0], state: "minecraft:air"},
{pos: [1, 4, 1], state: "minecraft:air"},
{pos: [1, 4, 2], state: "minecraft:air"},
{pos: [1, 4, 3], state: "minecraft:air"},
{pos: [1, 4, 4], state: "minecraft:air"},
{pos: [2, 4, 0], state: "minecraft:air"},
{pos: [2, 4, 1], state: "minecraft:air"},
{pos: [2, 4, 2], state: "minecraft:air"},
{pos: [2, 4, 3], state: "minecraft:air"},
{pos: [2, 4, 4], state: "minecraft:air"},
{pos: [3, 4, 0], state: "minecraft:air"},
{pos: [3, 4, 1], state: "minecraft:air"},
{pos: [3, 4, 2], state: "minecraft:air"},
{pos: [3, 4, 3], state: "minecraft:air"},
{pos: [3, 4, 4], state: "minecraft:air"},
{pos: [4, 4, 0], state: "minecraft:air"},
{pos: [4, 4, 1], state: "minecraft:air"},
{pos: [4, 4, 2], state: "minecraft:air"},
{pos: [4, 4, 3], state: "minecraft:air"},
{pos: [4, 4, 4], state: "minecraft:air"}
],
entities: [
{blockPos: [2, 1, 0], pos: [2.5773471891671482d, 1.0d, 0.31606672131648317d], nbt: {AbsorptionAmount: 0.0f, Air: 300s, ArmorItems: [{}, {}, {}, {}], Attributes: [{Base: 0.699999988079071d, Name: "minecraft:generic.movement_speed"}], Brain: {memories: {}}, DeathTime: 0s, DisabledSlots: 0, FallDistance: 0.0f, FallFlying: 0b, Fire: 0s, HandItems: [{}, {}], Health: 20.0f, HurtByTimestamp: 0, HurtTime: 0s, Invisible: 1b, Invulnerable: 0b, Marker: 1b, Motion: [0.0d, 0.0d, 0.0d], NoBasePlate: 0b, OnGround: 0b, PortalCooldown: 0, Pos: [-5.422652810832852d, -58.0d, 14.316066721316483d], Pose: {}, Rotation: [0.0f, 0.0f], ShowArms: 0b, Small: 0b, UUID: [I; -1438461995, 798246633, -1208936981, 1180880781], id: "minecraft:armor_stand"}}
],
palette: [
"minecraft:polished_andesite",
"minecraft:air",
"computercraft:monitor_advanced{facing:north,orientation:north,state:lu}",
"computercraft:monitor_advanced{facing:north,orientation:north,state:lru}",
"computercraft:monitor_advanced{facing:north,orientation:north,state:ru}",
"computercraft:monitor_advanced{facing:north,orientation:north,state:ld}",
"computercraft:monitor_advanced{facing:north,orientation:north,state:lrd}",
"computercraft:monitor_advanced{facing:north,orientation:north,state:rd}"
]
}

View File

@ -1,8 +1,6 @@
import cc.tweaked.gradle.annotationProcessorEverywhere
import cc.tweaked.gradle.clientClasses
import cc.tweaked.gradle.commonClasses
import cc.tweaked.gradle.mavenDependencies
import cc.tweaked.gradle.*
import net.fabricmc.loom.configuration.ide.RunConfigSettings
import java.util.*
plugins {
id("cc-tweaked.fabric")
@ -19,6 +17,23 @@ cct {
allProjects.forEach { externalSources(it) }
}
fun addRemappedConfiguration(name: String) {
val original = configurations.create(name) {
isCanBeConsumed = false
isCanBeResolved = true
}
val capitalName = name.capitalize(Locale.ROOT)
loom.addRemapConfiguration("mod$capitalName") {
onCompileClasspath.set(false)
onRuntimeClasspath.set(false)
targetConfigurationName.set(name)
}
original.extendsFrom(configurations["mod${capitalName}Mapped"])
}
addRemappedConfiguration("testWithSodium")
addRemappedConfiguration("testWithIris")
dependencies {
modImplementation(libs.bundles.externalMods.fabric)
modCompileOnly(libs.bundles.externalMods.fabric.compile) {
@ -30,6 +45,10 @@ dependencies {
exclude("net.fabricmc.fabric-api")
}
"modTestWithSodium"(libs.sodium)
"modTestWithIris"(libs.iris)
"modTestWithIris"(libs.sodium)
include(libs.cobalt)
include(libs.netty.http) // It might be better to shadowJar this, as we don't use half of it.
include(libs.forgeConfig)
@ -120,6 +139,7 @@ loom {
configureForGameTest(this)
runDir("run/testClient")
property("cctest.tags", "client,common")
}
register("gametest") {
@ -128,7 +148,7 @@ loom {
configureForGameTest(this)
property("fabric-api.gametest")
property("fabric-api.gametest.report-file", project.buildDir.resolve("test-results/gametest/gametest.xml").absolutePath)
property("fabric-api.gametest.report-file", project.buildDir.resolve("test-results/runGametest.xml").absolutePath)
runDir("run/gametest")
}
}
@ -158,14 +178,57 @@ val validateMixinNames by tasks.registering(net.fabricmc.loom.task.ValidateMixin
source(sourceSets.client.get().output)
source(sourceSets.testMod.get().output)
}
tasks.check { dependsOn(validateMixinNames) }
tasks.test { dependsOn(tasks.generateDLIConfig) }
val runGametest = tasks.named<JavaExec>("runGametest")
val runGametest = tasks.named<JavaExec>("runGametest") {
usesService(MinecraftRunnerService.get(gradle))
}
cct.jacoco(runGametest)
tasks.check { dependsOn(runGametest) }
tasks.check { dependsOn(validateMixinNames, runGametest) }
val runGametestClient by tasks.registering(ClientJavaExec::class) {
description = "Runs client-side gametests with no mods"
copyFrom("runTestClient")
tags("client")
}
cct.jacoco(runGametestClient)
val runGametestClientWithSodium by tasks.registering(ClientJavaExec::class) {
description = "Runs client-side gametests with Sodium"
copyFrom("runTestClient")
tags("sodium")
classpath += configurations["testWithSodium"]
}
cct.jacoco(runGametestClientWithSodium)
val runGametestClientWithIris by tasks.registering(ClientJavaExec::class) {
description = "Runs client-side gametests with Iris"
copyFrom("runTestClient")
tags("iris")
classpath += configurations["testWithIris"]
withFileFrom(workingDir.resolve("shaderpacks/ComplementaryShaders_v4.6.zip")) {
cct.downloadFile("Complementary Shaders", "https://edge.forgecdn.net/files/3951/170/ComplementaryShaders_v4.6.zip")
}
withFileContents(workingDir.resolve("config/iris.properties")) {
"""
enableShaders=true
shaderPack=ComplementaryShaders_v4.6.zip
""".trimIndent()
}
}
cct.jacoco(runGametestClientWithIris)
tasks.register("checkClient") {
group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs all client-only checks."
dependsOn(runGametestClient, runGametestClientWithSodium, runGametestClientWithIris)
}
tasks.withType(GenerateModuleMetadata::class).configureEach { isEnabled = false }
publishing {

View File

@ -10,9 +10,12 @@ import dan200.computercraft.export.Exporter;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.Event;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.gametest.framework.GameTestRegistry;
import net.minecraft.resources.ResourceLocation;
public class TestMod implements ModInitializer, ClientModInitializer {
@ -23,12 +26,15 @@ public class TestMod implements ModInitializer, ClientModInitializer {
var phase = new ResourceLocation(ComputerCraftAPI.MOD_ID, "test_mod");
ServerLifecycleEvents.SERVER_STARTED.addPhaseOrdering(Event.DEFAULT_PHASE, phase);
ServerLifecycleEvents.SERVER_STARTED.register(phase, TestHooks::onServerStarted);
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> CCTestCommand.register(dispatcher));
TestHooks.loadTests(GameTestRegistry::register);
}
@Override
public void onInitializeClient() {
ServerTickEvents.START_SERVER_TICK.register(ClientTestHooks::onServerTick);
ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> ClientTestHooks.onOpenScreen(screen));
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> Exporter.register(dispatcher));
}
}

View File

@ -8,17 +8,6 @@
],
"client": [
"dan200.computercraft.gametest.core.TestMod"
],
"fabric-gametest": [
"dan200.computercraft.gametest.Computer_Test",
"dan200.computercraft.gametest.CraftOs_Test",
"dan200.computercraft.gametest.Disk_Drive_Test",
"dan200.computercraft.gametest.Loot_Test",
"dan200.computercraft.gametest.Modem_Test",
"dan200.computercraft.gametest.Monitor_Test",
"dan200.computercraft.gametest.Printer_Test",
"dan200.computercraft.gametest.Recipe_Test",
"dan200.computercraft.gametest.Turtle_Test"
]
},
"mixins": [

View File

@ -101,6 +101,8 @@ minecraft {
workingDirectory(file("run/testClient"))
parent(client.get())
configureForGameTest()
property("cctest.tags", "client,common")
}
val gameTestServer by registering {
@ -254,18 +256,32 @@ val runGametest by tasks.registering(JavaExec::class) {
group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs tests on a temporary Minecraft instance."
dependsOn("cleanRunGametest")
usesService(MinecraftRunnerService.get(gradle))
// Copy from runGameTestServer. We do it in this slightly odd way as runGameTestServer
// isn't created until the task is configured (which is no good for us).
val exec = tasks.getByName<JavaExec>("runGameTestServer")
dependsOn(exec.dependsOn)
exec.copyToFull(this)
systemProperty("cctest.gametest-report", project.buildDir.resolve("test-results/$name.xml").absolutePath)
}
cct.jacoco(runGametest)
tasks.check { dependsOn(runGametest) }
val runGametestClient by tasks.registering(ClientJavaExec::class) {
description = "Runs client-side gametests with no mods"
copyFrom("runTestClient")
tags("client")
}
cct.jacoco(runGametestClient)
tasks.register("checkClient") {
group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs all client-only checks."
dependsOn(runGametestClient)
}
// Upload tasks
val publishCurseForge by tasks.registering(TaskPublishCurseForge::class) {

View File

@ -1,85 +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() {
var 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);
/*
if( minecraft.getLevelSource().levelExists( "test" ) )
{
LOG.info( "World exists, loading it" );
Minecraft.getInstance().loadLevel( "test" );
}
else
{
LOG.info( "World does not exist, creating it for the first time" );
RegistryAccess registries = RegistryAccess.builtinCopy();
Registry<DimensionType> dimensions = registries.registryOrThrow( Registry.DIMENSION_TYPE_REGISTRY );
var biomes = registries.registryOrThrow( Registry.BIOME_REGISTRY );
var structures = registries.registryOrThrow( Registry.STRUCTURE_SET_REGISTRY );
FlatLevelGeneratorSettings flatSettings = FlatLevelGeneratorSettings.getDefault( biomes, structures )
.withLayers(
Collections.singletonList( new FlatLayerInfo( 4, Blocks.WHITE_CONCRETE ) ),
Optional.empty()
);
flatSettings.setBiome( biomes.getHolderOrThrow( Biomes.DESERT ) );
WorldGenSettings generator = new WorldGenSettings( 0, false, false, withOverworld(
dimensions,
DimensionType.defaultDimensions( registries, 0 ),
new FlatLevelSource( structures, flatSettings )
) );
LevelSettings settings = new LevelSettings(
"test", GameType.CREATIVE, false, Difficulty.PEACEFUL, true,
new GameRules(), DataPackConfig.DEFAULT
);
Minecraft.getInstance().createLevel( "test", settings, registries, generator );
}
*/
}
}

View File

@ -6,31 +6,18 @@
package dan200.computercraft.gametest.core;
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.ModList;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
import net.minecraftforge.forgespi.language.ModFileScanData;
import net.minecraftforge.gametest.PrefixGameTestTemplate;
import org.objectweb.asm.Type;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Collection;
import java.util.Locale;
import java.util.function.Consumer;
@Mod("cctest")
public class TestMod {
@ -40,85 +27,21 @@ public class 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) -> {
var holder = Type.getType(GameTestHolder.class);
ModList.get().getAllScanData().stream()
.map(ModFileScanData::getAnnotations)
.flatMap(Collection::stream)
.filter(a -> holder.equals(a.annotationType()))
.forEach(x -> registerClass(x.clazz().getClassName(), event::register));
modBus.addListener((RegisterGameTestsEvent event) -> TestHooks.loadTests(event::register));
}
private static void onInitializeClient() {
var bus = MinecraftForge.EVENT_BUS;
bus.addListener((TickEvent.ServerTickEvent e) -> {
if (e.phase == TickEvent.Phase.START) ClientTestHooks.onServerTick(e.getServer());
});
}
private static Class<?> loadClass(String name) {
try {
return Class.forName(name, true, TestMod.class.getClassLoader());
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}
private static void registerClass(String className, Consumer<Method> 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);
}
}
/**
* 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.
* <p>
* 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 <T> Consumer<T> 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);
}
};
bus.addListener((ScreenEvent.Opening e) -> {
if (ClientTestHooks.onOpenScreen(e.getScreen())) e.setCanceled(true);
});
bus.addListener((RegisterClientCommandsEvent e) -> Exporter.register(e.getDispatcher()));
}
}

View File

@ -31,6 +31,8 @@ PROJECT_LOCATIONS = [
"projects/core",
"projects/common-api",
"projects/common",
"projects/fabric-api",
"projects/fabric",
"projects/forge-api",
"projects/forge",
]
@ -73,18 +75,16 @@ def find_location(message: str) -> Optional[Tuple[str, str]]:
def _parse_junit_file(path: pathlib.Path):
for testcase in ET.parse(path).getroot():
if testcase.tag != "testcase":
continue
for testcase in ET.parse(path).findall(".//testcase"):
for result in testcase:
if result.tag != "failure":
if result.tag == "skipped":
continue
name = f'{testcase.attrib["classname"]}.{testcase.attrib["name"]}'
message = result.attrib.get("message")
full_message = result.text or message
location = find_location(result.text)
location = find_location(full_message)
error = ERROR_MESSAGE.match(message)
if error:
error = error[1]
@ -97,7 +97,7 @@ def _parse_junit_file(path: pathlib.Path):
print(f"::error::{name} failed")
print("::group::Full error message")
print(result.text)
print(full_message)
print("::endgroup")
@ -112,6 +112,9 @@ def parse_junit() -> None:
for path in pathlib.Path(os.path.join(project, "build/test-results/test")).glob("TEST-*.xml"):
_parse_junit_file(path)
for path in pathlib.Path(os.path.join(project, "build/test-results")).glob("run*.xml"):
_parse_junit_file(path)
print("::remove-matcher owner=junit::")

113
tools/screenshots.py Executable file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
Combines screenshots from the Forge and Fabric tests into a single HTML page.
"""
import os
import os.path
from dataclasses import dataclass
from typing import TextIO
from textwrap import dedent
import webbrowser
import argparse
PROJECT_LOCATIONS = [
"projects/fabric",
"projects/forge",
]
@dataclass(frozen=True)
class Image:
name: list[str]
path: str
def write_images(io: TextIO, images: list[Image]):
io.write(
dedent(
"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CC:T test screenshots</title>
<style>
body {
padding: 0;
margin: 0;
font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
.content {
margin: 1em;
gap: 1em;
display: grid;
grid-template-columns: repeat(auto-fit,minmax(25em,1fr));
}
.image {
display: flex;
flex-direction: column;
}
.desc { text-align: center; }
.desc-prefix { color: #666; }
</style>
</head>
<body>
<div class="content">
"""
)
)
for image in images:
io.write(
dedent(
f"""\
<div class="image">
<img src="../{image.path}" />
<span class="desc">
<span class="desc-prefix">{" » ".join(image.name[:-2])} »</span>
<span class="desc-main">{image.name[-1]}</span>
</span>
</div>
"""
)
)
io.write("</div></body></html>")
def main():
spec = argparse.ArgumentParser(
description="Combines screenshots from the Forge and Fabric tests into a single HTML page."
)
spec.add_argument(
"--open",
default=False,
action="store_true",
help="Open the output file in a web browser.",
)
args = spec.parse_args()
images: list[Image] = []
for project, dir in {
"Forge": "projects/forge",
"Fabric": "projects/fabric",
}.items():
dir = os.path.join(dir, "build", "testScreenshots")
for file in sorted(os.listdir(dir)):
name = [project, *os.path.splitext(file)[0].split(".")]
images.append(Image(name, os.path.join(dir, file)))
out_file = "build/screenshots.html"
with open(out_file, encoding="utf-8", mode="w") as out:
write_images(out, images)
if args.open:
webbrowser.open(out_file)
if __name__ == "__main__":
main()