From 1fa2c3e8710f7a7da3a3fffe395840c89cdcf4ad Mon Sep 17 00:00:00 2001 From: Gabriel Harris-Rouquette Date: Wed, 21 Oct 2026 17:53:06 -0700 Subject: [PATCH] feat(plugin): run a plugin's gametests with gameTests() With Sponge's distribution of its gametest modules, there are a fair bit of wiring together of pieces to make it run properly in a plugin's development/CI environment. This now adds in the `sponge` extension a `gameTests()` option that does a default setup to run Game Tests on a headless server running SpongeVanilla. The classpath includes outputs from both the plugin's default outputs, as well as the (default defined) test outputs (default is `src/test`). The plugin goes through SPONGE_PLUGINS and its libraries through sponge.resources, which loads them into the game layer. This needs a Sponge whose installer keeps a launch's sponge.resources. Libraries the game already provides are filtered out, and the extension can exclude or include more. Signed-off-by: Gabriel Harris-Rouquette --- .../gradle/plugin/SpongePluginExtension.java | 20 ++ .../gradle/plugin/SpongePluginGradle.java | 267 ++++++++++++++++++ .../plugin/config/GameTestConfiguration.java | 146 ++++++++++ 3 files changed, 433 insertions(+) create mode 100644 plugin-development/src/main/java/org/spongepowered/gradle/plugin/config/GameTestConfiguration.java diff --git a/plugin-development/src/main/java/org/spongepowered/gradle/plugin/SpongePluginExtension.java b/plugin-development/src/main/java/org/spongepowered/gradle/plugin/SpongePluginExtension.java index 1864ecf..10cdb84 100644 --- a/plugin-development/src/main/java/org/spongepowered/gradle/plugin/SpongePluginExtension.java +++ b/plugin-development/src/main/java/org/spongepowered/gradle/plugin/SpongePluginExtension.java @@ -24,11 +24,14 @@ */ package org.spongepowered.gradle.plugin; +import org.gradle.api.Action; import org.gradle.api.NamedDomainObjectContainer; import org.gradle.api.model.ObjectFactory; import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Internal; import org.spongepowered.gradle.common.SpongePlatform; import org.spongepowered.gradle.plugin.config.ContainerLoaderConfiguration; +import org.spongepowered.gradle.plugin.config.GameTestConfiguration; import org.spongepowered.gradle.plugin.config.MetadataContainerConfiguration; import org.spongepowered.gradle.plugin.config.PluginConfiguration; import org.spongepowered.gradle.plugin.config.PluginInheritableConfiguration; @@ -49,6 +52,7 @@ public class SpongePluginExtension implements MetadataContainerConfiguration { private final Property minecraftVersion; private final Property apiVersion; private final Property injectRepositories; + private final GameTestConfiguration gameTests; @Inject public SpongePluginExtension(final ObjectFactory factory) { @@ -62,6 +66,7 @@ public SpongePluginExtension(final ObjectFactory factory) { this.minecraftVersion = factory.property(String.class); this.apiVersion = factory.property(String.class); this.injectRepositories = factory.property(Boolean.class); + this.gameTests = factory.newInstance(GameTestConfiguration.class); } @Override @@ -113,6 +118,21 @@ public void apiVersion(final String version) { this.apiVersion.set(version); } + /** The gametest setup, see {@link GameTestConfiguration}; configuring it turns it on. Not plugin metadata. */ + @Internal + public GameTestConfiguration getGameTests() { + return this.gameTests; + } + + public void gameTests() { + this.gameTests.getEnabled().set(true); + } + + public void gameTests(final Action action) { + this.gameTests.getEnabled().set(true); + action.execute(this.gameTests); + } + public Property injectRepositories() { return this.injectRepositories; } diff --git a/plugin-development/src/main/java/org/spongepowered/gradle/plugin/SpongePluginGradle.java b/plugin-development/src/main/java/org/spongepowered/gradle/plugin/SpongePluginGradle.java index 5dd4c69..cc13390 100644 --- a/plugin-development/src/main/java/org/spongepowered/gradle/plugin/SpongePluginGradle.java +++ b/plugin-development/src/main/java/org/spongepowered/gradle/plugin/SpongePluginGradle.java @@ -24,19 +24,30 @@ */ package org.spongepowered.gradle.plugin; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import net.kyori.mammoth.ProjectOrSettingsPlugin; import org.gradle.api.Action; +import org.gradle.api.GradleException; import org.gradle.api.NamedDomainObjectProvider; import org.gradle.api.Project; import org.gradle.api.Task; import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.Dependency; +import org.gradle.api.artifacts.ModuleVersionIdentifier; import org.gradle.api.artifacts.component.ComponentIdentifier; import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.component.ProjectComponentIdentifier; +import org.gradle.api.artifacts.dsl.DependencyHandler; import org.gradle.api.artifacts.dsl.RepositoryHandler; +import org.gradle.api.artifacts.result.DependencyResult; import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; import org.gradle.api.file.Directory; import org.gradle.api.file.FileCollection; +import org.gradle.api.file.FileSystemLocation; import org.gradle.api.initialization.Settings; import org.gradle.api.plugins.ExtensionContainer; import org.gradle.api.plugins.JavaLibraryPlugin; @@ -44,24 +55,44 @@ import org.gradle.api.plugins.PluginContainer; import org.gradle.api.provider.Property; import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Input; import org.gradle.api.tasks.JavaExec; import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSetContainer; import org.gradle.api.tasks.TaskContainer; import org.gradle.api.tasks.TaskProvider; import org.gradle.api.tasks.bundling.AbstractArchiveTask; +import org.gradle.api.tasks.testing.Test; import org.gradle.process.CommandLineArgumentProvider; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnknownNullability; import org.spongepowered.gradle.common.Constants; import org.spongepowered.gradle.common.SpongePlatform; +import org.spongepowered.gradle.plugin.config.GameTestConfiguration; import org.spongepowered.gradle.plugin.config.PluginConfiguration; import org.spongepowered.gradle.plugin.task.WritePluginMetadataTask; +import java.io.File; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; public final class SpongePluginGradle implements ProjectOrSettingsPlugin { @@ -96,6 +127,7 @@ public void applyToProject( this.addApiDependency(sponge); final NamedDomainObjectProvider spongeRuntime = this.addRuntimeDependency(sponge); final TaskProvider runServer = this.createRunTask(spongeRuntime, sponge); + this.configureGameTests(spongeRuntime, sponge); project.afterEvaluate(a -> { if (sponge.minecraftVersion().isPresent() && sponge.apiVersion().isPresent()) { @@ -318,6 +350,206 @@ public void execute(final JavaExec it) { return runServer; } + /** + * Gametests through the {@code test} task, when {@code sponge.gameTests} is configured: the platform's gametest module + * on the test classpath, at the version of the Sponge runtime unless one is given; the Sponge runtime on the test + * runtime classpath, whose JUnit launcher interceptor boots the game before discovery; the plugin's own files as one + * plugin resource through Sponge's {@code SPONGE_PLUGINS} environment locator; and the rest of the test runtime + * classpath as game-layer libraries through {@code sponge.resources}. + */ + private void configureGameTests(final NamedDomainObjectProvider spongeRuntime, final SpongePluginExtension sponge) { + final GameTestConfiguration gameTests = sponge.getGameTests(); + final Provider version = gameTests.getVersion().orElse(this.project.provider(() -> { + for (final ResolvedArtifactResult artifact : spongeRuntime.get().getIncoming().artifactView(view -> view.setLenient(true)).getArtifacts()) { + final ComponentIdentifier id = artifact.getVariant().getOwner(); + if (id instanceof ModuleComponentIdentifier + && ((ModuleComponentIdentifier) id).getModule().equals(sponge.platform().get().artifactId())) { + return ((ModuleComponentIdentifier) id).getVersion(); + } + } + throw new GradleException("No Sponge runtime to take the gametest module version from; set sponge.gameTests.version"); + })); + final Provider module = version.map(v -> Constants.Dependencies.SPONGE_GROUP + ":" + sponge.platform().get().artifactId() + "-gametest:" + v); + this.project.getPlugins().withType(JavaPlugin.class, v -> this.project.afterEvaluate(p -> { + if (!gameTests.getEnabled().get()) { + return; + } + final SourceSetContainer sourceSets = p.getExtensions().getByType(SourceSetContainer.class); + final SourceSet test = sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME); + final SourceSet main = sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME); + if (gameTests.getPluginFiles().isEmpty()) { // the default; ConfigurableFileCollection.convention needs Gradle 8.8 + // This project's outputs, and the projects it depends on: a multi-module plugin's other parts + final Configuration runtime = p.getConfigurations().getByName(test.getRuntimeClasspathConfigurationName()); + gameTests.getPluginFiles().from(main.getOutput(), test.getOutput(), + runtime.getIncoming().artifactView(view -> view.componentFilter(id -> id instanceof ProjectComponentIdentifier)).getFiles()); + } + gameTests.getWorkingDirectory().convention(p.getLayout().getBuildDirectory().dir("gametest-run")); + gameTests.getPluginId().convention(p.provider(() -> sponge.getPlugins().size() == 1 ? sponge.getPlugins().iterator().next().getName() : null)); + + final DependencyHandler dependencies = p.getDependencies(); + // The test declaration and engine to compile against, and, being a plugin itself, found on the classpath at runtime + dependencies.addProvider(test.getCompileOnlyConfigurationName(), module); + dependencies.addProvider(test.getRuntimeOnlyConfigurationName(), module); + dependencies.add(test.getRuntimeOnlyConfigurationName(), p.files(spongeRuntime)); + // A plugin built against Sponge's dev jar must not load it as a second copy of Sponge; the runtime provides it + final Map spongeDevJar = new HashMap<>(); + spongeDevJar.put("group", Constants.Dependencies.SPONGE_GROUP); + spongeDevJar.put("module", "sponge"); + p.getConfigurations().named(test.getRuntimeClasspathConfigurationName(), c -> c.exclude(spongeDevJar)); + + p.getTasks().named(JavaPlugin.TEST_TASK_NAME, Test.class, task -> { + task.useJUnitPlatform(); + task.getInputs().files(gameTests.getPluginFiles()); + // Where the engine looks for the tests: the compiled test classes, under the plugin they belong to + final Provider roots = test.getOutput().getClassesDirs().getElements() + .map(dirs -> dirs.stream().map(dir -> dir.getAsFile().getAbsolutePath()).collect(Collectors.joining("&"))) + .zip(gameTests.getPluginId().orElse(""), (dirs, id) -> id.isEmpty() ? dirs : id + "=" + dirs); + task.getJvmArgumentProviders().add(new GameTestArguments(gameTests.getLaunchTarget(), roots)); + // The plugin: Sponge unions these paths with the last one winning, manifest included, so any jar goes before + // the plugin's own directories, whose META-INF/MANIFEST.MF carries its mixin and access widener attributes. + final Provider plugin = gameTests.getPluginFiles().getElements() + .map(files -> files.stream() + .map(FileSystemLocation::getAsFile) + .sorted(Comparator.comparing(File::isDirectory)) + .map(File::getAbsolutePath) + .collect(Collectors.joining("&"))); + // Its libraries: whatever was added, then the rest of the test runtime classpath, each its own game-layer + // module as on a dev run's classpath. Anything the game loads itself stays out, so the plugin runs against + // the game's copy as it does in production, and JUnit stays out since the launcher must keep its own. + final Provider> excludes = gameTests.getExcludes(); + final Configuration runtime = p.getConfigurations().getByName(test.getRuntimeClasspathConfigurationName()); + final GameModules game = new GameModules(runtime, spongeRuntime.get()); + gameTests.getLibraries().from(runtime.getIncoming().artifactView(view -> view.componentFilter(id -> SpongePluginGradle.isLibrary(id) + && !game.contains(id) && !SpongePluginGradle.isExcluded(id, excludes.get()))).getFiles()); + task.getInputs().files(gameTests.getLibraries()); + // Passed through sponge.resources, which loads them into the game layer whether or not they are plugins and + // keeps the app classpath's copies out of it; the installer puts the game's own libraries ahead of them + final Provider libraries = gameTests.getLibraries().getElements() + .map(libs -> libs.stream().map(l -> l.getAsFile().getAbsolutePath()).collect(Collectors.joining(File.pathSeparator))); + task.doFirst(new Action() { + @Override + public void execute(final Task t) { + final Test run = (Test) t; + run.environment("SPONGE_PLUGINS", plugin.get()); + run.systemProperty("sponge.resources", libraries.get()); + final Path directory = gameTests.getWorkingDirectory().get().getAsFile().toPath(); + run.setWorkingDir(directory.toFile()); + try { + Files.createDirectories(directory); + Files.write(directory.resolve("eula.txt"), Collections.singletonList("eula=true")); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + } + }); + }); + })); + } + + /** + * The modules the game loads itself, as {@code group:module}: Minecraft and the API with everything they depend on, and + * the libraries the Sponge runtime installs, which its jar lists in {@code sponge-libraries.json}. Versions are left + * out, since the game's copy is the one a server loads whatever the plugin was built against. + */ + private static final class GameModules { + + private static final String LIBRARIES = "sponge-libraries.json"; + + private final Configuration runtime; + private final Configuration spongeRuntime; + private @Nullable Set modules; + + GameModules(final Configuration runtime, final Configuration spongeRuntime) { + this.runtime = runtime; + this.spongeRuntime = spongeRuntime; + } + + boolean contains(final ComponentIdentifier id) { + if (!(id instanceof ModuleComponentIdentifier)) { + return false; + } + final ModuleComponentIdentifier module = (ModuleComponentIdentifier) id; + return this.modules().contains(module.getGroup() + ":" + module.getModule()); + } + + // Resolved on first use, which is when the test task resolves its libraries + private Set modules() { + if (this.modules == null) { + final Set modules = new HashSet<>(); + final Deque pending = new ArrayDeque<>(); + for (final ResolvedComponentResult component : this.runtime.getIncoming().getResolutionResult().getAllComponents()) { + final @Nullable ModuleVersionIdentifier id = component.getModuleVersion(); + if (id != null && (id.getGroup().equals("net.minecraft") + || id.getGroup().equals(Constants.Dependencies.SPONGE_GROUP) && id.getName().equals(Constants.Dependencies.SPONGE_API))) { + pending.add(component); + } + } + while (!pending.isEmpty()) { + final ResolvedComponentResult component = pending.poll(); + final @Nullable ModuleVersionIdentifier id = component.getModuleVersion(); + if (id != null && !modules.add(id.getGroup() + ":" + id.getName())) { + continue; + } + for (final DependencyResult dependency : component.getDependencies()) { + if (dependency instanceof ResolvedDependencyResult) { + pending.add(((ResolvedDependencyResult) dependency).getSelected()); + } + } + } + for (final File file : this.spongeRuntime.getFiles()) { + GameModules.installed(file, modules); + } + this.modules = modules; + } + return this.modules; + } + + private static void installed(final File jar, final Set modules) { + if (!jar.getName().endsWith(".jar")) { + return; + } + try (final ZipFile zip = new ZipFile(jar)) { + final @Nullable ZipEntry entry = zip.getEntry(GameModules.LIBRARIES); + if (entry == null) { + return; + } + try (final Reader reader = new InputStreamReader(zip.getInputStream(entry), StandardCharsets.UTF_8)) { + // {"dependencies": {"": [{"group": ..., "module": ..., "version": ...}, ...], ...}} + for (final Map.Entry layer : JsonParser.parseReader(reader).getAsJsonObject().getAsJsonObject("dependencies").entrySet()) { + for (final JsonElement library : layer.getValue().getAsJsonArray()) { + final JsonObject coordinates = library.getAsJsonObject(); + modules.add(coordinates.get("group").getAsString() + ":" + coordinates.get("module").getAsString()); + } + } + } + } catch (final IOException e) { + throw new UncheckedIOException("Reading " + GameModules.LIBRARIES + " from " + jar, e); + } + } + } + + private static boolean isExcluded(final ComponentIdentifier id, final Set excludes) { + if (!(id instanceof ModuleComponentIdentifier)) { + return false; + } + final ModuleComponentIdentifier module = (ModuleComponentIdentifier) id; + return excludes.contains(module.getGroup()) || excludes.contains(module.getGroup() + ":" + module.getModule()); + } + + private static boolean isLibrary(final ComponentIdentifier id) { + if (!(id instanceof ModuleComponentIdentifier)) { + return false; // a project is a part of the plugin (see the plugin files default), a file the Sponge runtime jar + } + final ModuleComponentIdentifier module = (ModuleComponentIdentifier) id; + final String group = module.getGroup(); + return !group.equals("net.minecraft") + && !group.startsWith("org.junit") + && !group.equals("org.opentest4j") + && !group.equals("org.apiguardian") + && !(group.equals(Constants.Dependencies.SPONGE_GROUP) + && (module.getModule().equals(Constants.Dependencies.SPONGE_API) || module.getModule().endsWith("-gametest"))); + } + private void configurePluginMetaGeneration(final SpongePluginExtension sponge) { // Configure some useful default values sponge.getPlugins().configureEach(new ConfigurePluginAction(this.project, sponge)); @@ -331,6 +563,8 @@ private void configurePluginMetaGeneration(final SpongePluginExtension sponge) { public void execute(final WritePluginMetadataTask task) { task.getSourceContainer().set(sponge); task.getOutputDirectory().set(generatedResourcesDirectory); + // A module of a plugin whose metadata another module writes declares no plugin of its own + task.onlyIf(t -> !sponge.getPlugins().isEmpty()); } } ); @@ -345,6 +579,39 @@ public void execute(final SourceSet s) { }); } + /** + * The JVM arguments that make the test JVM boot the game, Sponge's launcher interceptor and what it launches, and + * tell the gametest engine where the tests are + */ + public static final class GameTestArguments implements CommandLineArgumentProvider { + private final Provider launchTarget; + private final Provider roots; + + GameTestArguments(final Provider launchTarget, final Provider roots) { + this.launchTarget = launchTarget; + this.roots = roots; + } + + @Input + public Provider getLaunchTarget() { + return this.launchTarget; + } + + @Input + public Provider getRoots() { + return this.roots; + } + + @Override + public Iterable asArguments() { + return Arrays.asList( + "-Djunit.platform.launcher.interceptors.enabled=true", + "-Dsponge.test.args=--launchTarget " + this.launchTarget.get(), + "-Dsponge.gametest.roots=" + this.roots.get() + ); + } + } + private static class ConfigurePluginAction implements Action { private final Provider displayName; private final Provider version; diff --git a/plugin-development/src/main/java/org/spongepowered/gradle/plugin/config/GameTestConfiguration.java b/plugin-development/src/main/java/org/spongepowered/gradle/plugin/config/GameTestConfiguration.java new file mode 100644 index 0000000..b60650e --- /dev/null +++ b/plugin-development/src/main/java/org/spongepowered/gradle/plugin/config/GameTestConfiguration.java @@ -0,0 +1,146 @@ +/* + * This file is part of spongegradle-plugin-development, licensed under the MIT License (MIT). + * + * Copyright (c) SpongePowered + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.spongepowered.gradle.plugin.config; + +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.model.ObjectFactory; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.SetProperty; + +import javax.inject.Inject; + +/** + * Runs the plugin's gametests, the {@code @GameTest} methods under {@code src/test}, through the {@code test} task: + * the platform's gametest module is added to the test classpath, the Sponge runtime boots in the test JVM before + * JUnit discovers anything, and the plugin's own output is handed to it to load. Every value has a working default. + */ +public abstract class GameTestConfiguration { + + private final Property enabled; + private final Property version; + private final Property launchTarget; + private final Property pluginId; + private final ConfigurableFileCollection pluginFiles; + private final ConfigurableFileCollection libraries; + private final SetProperty excludes; + private final DirectoryProperty workingDirectory; + + @Inject + public GameTestConfiguration(final ObjectFactory factory) { + this.enabled = factory.property(Boolean.class).convention(false); + this.version = factory.property(String.class); + this.launchTarget = factory.property(String.class).convention("sponge_server_gametest"); + this.pluginId = factory.property(String.class); + this.pluginFiles = factory.fileCollection(); + this.libraries = factory.fileCollection(); + this.excludes = factory.setProperty(String.class); + this.workingDirectory = factory.directoryProperty(); + } + + /** Whether the {@code test} task runs gametests; {@code sponge.gameTests { }} turns it on. */ + public Property getEnabled() { + return this.enabled; + } + + /** + * The version of the platform's gametest module, {@code org.spongepowered:-gametest}. Defaults to the + * version of the Sponge runtime {@code runServer} resolves, so the two always match. + */ + public Property getVersion() { + return this.version; + } + + public void version(final String version) { + this.version.set(version); + } + + /** The launch target the Sponge runtime boots with; the headless game test server by default. */ + public Property getLaunchTarget() { + return this.launchTarget; + } + + public void launchTarget(final String launchTarget) { + this.launchTarget.set(launchTarget); + } + + /** + * The plugin the tests belong to, which prefixes their names: {@code ..}. Defaults to + * the plugin this project declares, when it declares exactly one; a module whose metadata another module writes names + * it here. + */ + public Property getPluginId() { + return this.pluginId; + } + + public void pluginId(final String pluginId) { + this.pluginId.set(pluginId); + } + + /** + * What Sponge loads as the plugin under test, as one plugin resource: by default the main and test outputs and the + * jars of the projects this one depends on, a multi-module plugin's other parts. A built jar can stand in, e.g. + * {@code plugin(tasks.named("shadowJar"))}. Jars are listed before directories, since Sponge reads the manifest + * from the last path. + */ + public ConfigurableFileCollection getPluginFiles() { + return this.pluginFiles; + } + + public void plugin(final Object... paths) { + this.pluginFiles.setFrom(paths); + } + + /** + * What Sponge loads beside the plugin as game-layer libraries, each its own module. The modules on the test runtime + * classpath are there by default, less JUnit and anything the game loads itself: Minecraft and the API with their + * dependencies, and the libraries the Sponge runtime installs. {@code libraries(...)} adds to that: a file + * dependency, a configuration, a task's jar. + */ + public ConfigurableFileCollection getLibraries() { + return this.libraries; + } + + public void libraries(final Object... paths) { + this.libraries.from(paths); + } + + /** + * Modules of the test runtime classpath kept out of the libraries, as {@code group:module} or a whole {@code group}: + * what another platform's module brings along, or a test-only dependency the game should not see. + */ + public SetProperty getExcludes() { + return this.excludes; + } + + public void exclude(final String... modules) { + this.excludes.addAll(modules); + } + + /** Where the game test server runs; {@code build/gametest-run} by default, with the EULA accepted. */ + public DirectoryProperty getWorkingDirectory() { + return this.workingDirectory; + } +}