From 1a70c3703bc905c5ff98db67f2d4f092a18f8691 Mon Sep 17 00:00:00 2001 From: Finn Rades <64548817+zOnlyKroks@users.noreply.github.com> Date: Sun, 9 Nov 2025 11:44:31 +0100 Subject: [PATCH] Add XVFB support for headless client execution (#1432) * Implement XVFB support * Remove unused import * Fix test * Fix test v2 * Explicitly install xvfb into the test environment * Rework xfvb execution * Fix compile error * Fix compile error v2 * We love testing with github ci * Fix code-style Build time speedup * Build time speedup * Fix java executable access Fix caching * Fix styling * Fix xvfb again * Fix xvfb again again * Fix xvfb again again again * Revert mistaken change * Fix MC-DEV * Update src/test/groovy/net/fabricmc/loom/test/integration/XvfbRunTest.groovy Co-authored-by: modmuss * Cleanup * Fix test * Implement recommendations * Implement recommendations v2 * Fix left over code --------- Co-authored-by: modmuss --- .../loom/api/LoomGradleExtensionAPI.java | 2 + .../extension/LoomGradleExtensionApiImpl.java | 2 +- .../fabricmc/loom/task/AbstractRunTask.java | 45 ++++++++++++++++++- .../test/integration/RunConfigTest.groovy | 37 +++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java b/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java index f1284d9d..120a48e2 100644 --- a/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java +++ b/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java @@ -224,6 +224,8 @@ public interface LoomGradleExtensionAPI { /** * Returns the tiny mappings file used to remap the game and mods. + * + * @return the mappings file, or null if in a non-obfuscated environment */ File getMappingsFile(); diff --git a/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java b/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java index f3cff2a8..0b894722 100644 --- a/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java +++ b/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java @@ -366,7 +366,7 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA @Override public File getMappingsFile() { if (notObfuscated()) { - throw new UnsupportedOperationException("Cannot get mappings file in a non-obfuscated environment"); + return null; } return LoomGradleExtension.get(getProject()).getMappingConfiguration().tinyMappings.toFile(); diff --git a/src/main/java/net/fabricmc/loom/task/AbstractRunTask.java b/src/main/java/net/fabricmc/loom/task/AbstractRunTask.java index 8a0a4b72..f19595f2 100644 --- a/src/main/java/net/fabricmc/loom/task/AbstractRunTask.java +++ b/src/main/java/net/fabricmc/loom/task/AbstractRunTask.java @@ -37,6 +37,8 @@ import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; +import javax.inject.Inject; + import org.gradle.api.Project; import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.FileCollection; @@ -48,6 +50,7 @@ import org.gradle.api.specs.Spec; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.JavaExec; +import org.gradle.process.ExecOperations; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,11 +58,15 @@ import org.slf4j.LoggerFactory; import net.fabricmc.loom.LoomGradleExtension; import net.fabricmc.loom.configuration.ide.RunConfig; import net.fabricmc.loom.util.Constants; +import net.fabricmc.loom.util.Platform; public abstract class AbstractRunTask extends JavaExec { private static final CharsetEncoder ASCII_ENCODER = StandardCharsets.US_ASCII.newEncoder(); private static final Logger LOGGER = LoggerFactory.getLogger(AbstractRunTask.class); + @Inject + protected abstract ExecOperations getExecOperations(); + @Input protected abstract Property getInternalRunDir(); @Input @@ -73,6 +80,8 @@ public abstract class AbstractRunTask extends JavaExec { @Input // We use a string here, as it's technically an output, but we don't want to cache runs of this task by default. protected abstract Property getArgFilePath(); + @Input + protected abstract Property getUseXvfb(); // We control the classpath, as we use a ArgFile to pass it over the command line: https://docs.oracle.com/javase/7/docs/technotes/tools/windows/javac.html#commandlineargfile @InputFiles @@ -100,6 +109,13 @@ public abstract class AbstractRunTask extends JavaExec { getUseArgFile().set(getProject().provider(this::canUseArgFile)); getProjectDir().set(getProject().getProjectDir().getAbsolutePath()); + // Set up useXvfb: convention is CI + Linux + getUseXvfb().convention( + getProject().getProviders().environmentVariable("CI") + .map(value -> Platform.CURRENT.getOperatingSystem().isLinux()) + .orElse(false) + ); + File buildCache = LoomGradleExtension.get(getProject()).getFiles().getProjectBuildCache(); File argFile = new File(buildCache, "argFiles/" + getName()); getArgFilePath().set(argFile.getAbsolutePath()); @@ -135,7 +151,34 @@ public abstract class AbstractRunTask extends JavaExec { setWorkingDir(new File(getProjectDir().get(), getInternalRunDir().get())); environment(getInternalEnvironmentVars().get()); - super.exec(); + // Wrap with XVFB if enabled and on Linux + if (getUseXvfb().get()) { + LOGGER.info("Using XVFB for headless client execution"); + execWithXvfb(); + } else { + super.exec(); + } + } + + private void execWithXvfb() { + String xvfbRunPath = "/usr/bin/xvfb-run"; + + String javaExec = getJavaLauncher().get().getExecutablePath().getAsFile().getAbsolutePath(); + + // Build the complete command line: xvfb-run --auto-servernum java [jvm-args] mainclass [program-args] + List commandLine = new ArrayList<>(); + commandLine.add(xvfbRunPath); + commandLine.add("--auto-servernum"); + commandLine.add(javaExec); + commandLine.addAll(getJvmArguments().get()); + commandLine.add(getMainClass().get()); + commandLine.addAll(getArgs()); + + getExecOperations().exec(execSpec -> { + execSpec.setCommandLine(commandLine); + execSpec.setWorkingDir(getWorkingDir()); + execSpec.setEnvironment(getEnvironment()); + }); } @Override diff --git a/src/test/groovy/net/fabricmc/loom/test/integration/RunConfigTest.groovy b/src/test/groovy/net/fabricmc/loom/test/integration/RunConfigTest.groovy index 7a8cacb7..d293e367 100644 --- a/src/test/groovy/net/fabricmc/loom/test/integration/RunConfigTest.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/integration/RunConfigTest.groovy @@ -208,4 +208,41 @@ class RunConfigTest extends Specification implements GradleProjectTestTrait { where: version << STANDARD_TEST_VERSIONS } + + @Unroll + @IgnoreIf({ !os.linux }) + def "client game tests with XVFB (gradle #version)"() { + setup: + def gradle = gradleProject(project: "minimalBase", version: version) + gradle.buildGradle << ''' + dependencies { + minecraft "com.mojang:minecraft:1.21.4" + mappings "net.fabricmc:yarn:1.21.4+build.4:v2" + modImplementation "net.fabricmc:fabric-loader:0.16.9" + modImplementation "net.fabricmc.fabric-api:fabric-api:0.114.0+1.21.4" + } + + fabricApi { + configureTests { + createSourceSet = true + modId = "example-test" + eula = true + } + } + + tasks.named("runClientGameTest") { + useXvfb.set(true) + } + ''' + when: + def result = gradle.run(task: "runClientGameTest") + def eula = new File(gradle.projectDir, "build/run/clientGameTest/eula.txt") + + then: + result.task(":runClientGameTest").outcome == SUCCESS + eula.text.contains("eula=true") + + where: + version << STANDARD_TEST_VERSIONS + } }